durable_streams-rails 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/MIT-LICENSE +20 -0
- data/Rakefile +10 -0
- data/app/jobs/durable_streams/broadcast_job.rb +9 -0
- data/app/models/concerns/durable_streams/broadcastable.rb +202 -0
- data/lib/durable_streams/broadcastable/test_helper.rb +157 -0
- data/lib/durable_streams/broadcasts.rb +55 -0
- data/lib/durable_streams/engine.rb +45 -0
- data/lib/durable_streams/stream_name.rb +24 -0
- data/lib/durable_streams/testing.rb +33 -0
- data/lib/durable_streams/version.rb +3 -0
- data/lib/durable_streams-rails.rb +30 -0
- metadata +83 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: ee437ac73b405cba8114f0a9b38a69f20a70fceb86aa80998b82396af577b133
|
|
4
|
+
data.tar.gz: ce7306811c5b50cab5490102353399a2e14ca1bf23564c93931c0d5344f656b5
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 7dba804eeefe0a329b0cc314cfcd595982a0d3eaf5b7e359fd5816998f14668ff28453017c826e7f3cc693c5065e20a81ab68b4cb995c7424da27cbc3956b50f
|
|
7
|
+
data.tar.gz: 71f865a9adec22871977e3e97b7cf2ce4ac7a3d5902f6e0583737c3388c93b257b98ee96240613ca2a9e1829aadb8bcb22ed27796e90508a5f9a9b93415e7d25
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright (c) tokimonki
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/Rakefile
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# The job that powers all the <tt>stream_*_later_to</tt> broadcasts available in <tt>DurableStreams::Broadcastable</tt>.
|
|
2
|
+
class DurableStreams::BroadcastJob < ActiveJob::Base
|
|
3
|
+
discard_on ActiveJob::DeserializationError
|
|
4
|
+
retry_on DurableStreams::ConnectionError, wait: :polynomially_longer, attempts: 10
|
|
5
|
+
|
|
6
|
+
def perform(stream_name, type:, key:, value:, operation:)
|
|
7
|
+
DurableStreams.broadcast_event_to stream_name, type: type, key: key, value: value, operation: operation
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# Durable Streams can be broadcast directly from models that include this module (automatically
|
|
2
|
+
# done for Active Records via the engine). This makes it convenient to execute both synchronous
|
|
3
|
+
# and asynchronous State Protocol broadcasts from callbacks or controllers. Here's an example:
|
|
4
|
+
#
|
|
5
|
+
# class Comment < ApplicationRecord
|
|
6
|
+
# belongs_to :post
|
|
7
|
+
#
|
|
8
|
+
# after_create_commit :broadcast_later
|
|
9
|
+
#
|
|
10
|
+
# private
|
|
11
|
+
# def broadcast_later
|
|
12
|
+
# stream_insert_later_to post
|
|
13
|
+
# end
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# This broadcasts a State Protocol insert event to the stream derived from the post association.
|
|
17
|
+
# All clients subscribed to that stream will receive the event as an SSE message.
|
|
18
|
+
#
|
|
19
|
+
# There are four basic operations you can broadcast: <tt>insert</tt>, <tt>update</tt>, <tt>upsert</tt>,
|
|
20
|
+
# and <tt>delete</tt>. As a rule, you should use the <tt>_later</tt> versions when broadcasting within
|
|
21
|
+
# a real-time path, like a controller or model callback, since those go through a background job.
|
|
22
|
+
#
|
|
23
|
+
# == Declarative streaming
|
|
24
|
+
#
|
|
25
|
+
# For the common case of broadcasting creates, updates, and destroys, you can use the class-level
|
|
26
|
+
# <tt>streams_to</tt> declaration:
|
|
27
|
+
#
|
|
28
|
+
# class Comment < ApplicationRecord
|
|
29
|
+
# belongs_to :post
|
|
30
|
+
# streams_to :post
|
|
31
|
+
# end
|
|
32
|
+
#
|
|
33
|
+
# This is equivalent to registering +after_create_commit+, +after_update_commit+, and
|
|
34
|
+
# +after_destroy_commit+ callbacks that broadcast the appropriate State Protocol events.
|
|
35
|
+
#
|
|
36
|
+
# When the stream target is the model itself, use the self-targeting <tt>streams</tt> declaration:
|
|
37
|
+
#
|
|
38
|
+
# class Board < ApplicationRecord
|
|
39
|
+
# streams
|
|
40
|
+
# end
|
|
41
|
+
#
|
|
42
|
+
# == Suppressing broadcasts
|
|
43
|
+
#
|
|
44
|
+
# Sometimes, you need to disable broadcasts in certain scenarios. You can use <tt>.suppressing_streams</tt>
|
|
45
|
+
# to create execution contexts where broadcasts are disabled:
|
|
46
|
+
#
|
|
47
|
+
# Comment.suppressing_streams do
|
|
48
|
+
# Comment.create!(post: post) # This won't broadcast the insert event
|
|
49
|
+
# end
|
|
50
|
+
module DurableStreams::Broadcastable
|
|
51
|
+
extend ActiveSupport::Concern
|
|
52
|
+
|
|
53
|
+
included do
|
|
54
|
+
thread_mattr_accessor :suppressed_streams, instance_accessor: false
|
|
55
|
+
delegate :suppressed_streams?, to: "self.class"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
module ClassMethods
|
|
59
|
+
# Configures the model to broadcast creates, updates, and destroys to a stream name derived at runtime by the
|
|
60
|
+
# <tt>stream</tt> symbol invocation. Examples:
|
|
61
|
+
#
|
|
62
|
+
# class Comment < ApplicationRecord
|
|
63
|
+
# belongs_to :post
|
|
64
|
+
# streams_to :post
|
|
65
|
+
# end
|
|
66
|
+
#
|
|
67
|
+
# class Comment < ApplicationRecord
|
|
68
|
+
# belongs_to :post
|
|
69
|
+
# streams_to ->(comment) { [ comment.post, :comments ] }
|
|
70
|
+
# end
|
|
71
|
+
def streams_to(stream)
|
|
72
|
+
after_create_commit -> { stream_insert_later_to(stream.try(:call, self) || send(stream)) }
|
|
73
|
+
after_update_commit -> { stream_update_later_to(stream.try(:call, self) || send(stream)) }
|
|
74
|
+
after_destroy_commit -> { stream_delete_later_to(stream.try(:call, self) || send(stream)) }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Same as <tt>#streams_to</tt>, but the designated stream is automatically set to the current model,
|
|
78
|
+
# which can be overridden by passing <tt>stream</tt>. Examples:
|
|
79
|
+
#
|
|
80
|
+
# class Board < ApplicationRecord
|
|
81
|
+
# streams
|
|
82
|
+
# end
|
|
83
|
+
#
|
|
84
|
+
# class Board < ApplicationRecord
|
|
85
|
+
# streams "boards"
|
|
86
|
+
# end
|
|
87
|
+
def streams(stream = model_name.plural)
|
|
88
|
+
after_create_commit -> { stream_insert_later_to(stream) }
|
|
89
|
+
after_update_commit -> { stream_update_later }
|
|
90
|
+
after_destroy_commit -> { stream_delete_later }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Executes +block+ preventing both synchronous and asynchronous broadcasts from this model.
|
|
94
|
+
def suppressing_streams(&block)
|
|
95
|
+
original, self.suppressed_streams = self.suppressed_streams, true
|
|
96
|
+
yield
|
|
97
|
+
ensure
|
|
98
|
+
self.suppressed_streams = original
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def suppressed_streams?
|
|
102
|
+
suppressed_streams
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Broadcast a State Protocol insert event to the stream identified by the passed <tt>streamables</tt>.
|
|
107
|
+
# Returns the +txid+ that can be used for optimistic update confirmation. Example:
|
|
108
|
+
#
|
|
109
|
+
# # Broadcasts {"type":"message","key":"5","value":{...},"headers":{"operation":"insert"}}
|
|
110
|
+
# txid = message.stream_insert_to post, :messages
|
|
111
|
+
# render json: { txid: txid }
|
|
112
|
+
def stream_insert_to(*streamables)
|
|
113
|
+
DurableStreams.broadcast_event_to(*streamables, **stream_event_attributes(operation: :insert)) unless suppressed_streams?
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Same as <tt>#stream_insert_to</tt>, but the designated stream is automatically set to the current model.
|
|
117
|
+
def stream_insert
|
|
118
|
+
stream_insert_to self
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Broadcast a State Protocol update event to the stream identified by the passed <tt>streamables</tt>. Example:
|
|
122
|
+
#
|
|
123
|
+
# message.stream_update_to post
|
|
124
|
+
def stream_update_to(*streamables)
|
|
125
|
+
DurableStreams.broadcast_event_to(*streamables, **stream_event_attributes(operation: :update)) unless suppressed_streams?
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Same as <tt>#stream_update_to</tt>, but the designated stream is automatically set to the current model.
|
|
129
|
+
def stream_update
|
|
130
|
+
stream_update_to self
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Broadcast a State Protocol upsert event to the stream identified by the passed <tt>streamables</tt>. Example:
|
|
134
|
+
#
|
|
135
|
+
# message.stream_upsert_to post
|
|
136
|
+
def stream_upsert_to(*streamables)
|
|
137
|
+
DurableStreams.broadcast_event_to(*streamables, **stream_event_attributes(operation: :upsert)) unless suppressed_streams?
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Same as <tt>#stream_upsert_to</tt>, but the designated stream is automatically set to the current model.
|
|
141
|
+
def stream_upsert
|
|
142
|
+
stream_upsert_to self
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Broadcast a State Protocol delete event to the stream identified by the passed <tt>streamables</tt>.
|
|
146
|
+
# The value is nil — only the key is needed for deletes. Example:
|
|
147
|
+
#
|
|
148
|
+
# message.stream_delete_to post
|
|
149
|
+
def stream_delete_to(*streamables)
|
|
150
|
+
DurableStreams.broadcast_event_to(*streamables, **stream_event_attributes(operation: :delete, value: nil)) unless suppressed_streams?
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Same as <tt>#stream_delete_to</tt>, but the designated stream is automatically set to the current model.
|
|
154
|
+
def stream_delete
|
|
155
|
+
stream_delete_to self
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Same as <tt>#stream_insert_to</tt> but run asynchronously via a <tt>DurableStreams::BroadcastJob</tt>.
|
|
159
|
+
def stream_insert_later_to(*streamables)
|
|
160
|
+
DurableStreams.broadcast_event_later_to(*streamables, **stream_event_attributes(operation: :insert)) unless suppressed_streams?
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Same as <tt>#stream_insert_later_to</tt>, but the designated stream is automatically set to the current model.
|
|
164
|
+
def stream_insert_later
|
|
165
|
+
stream_insert_later_to self
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Same as <tt>#stream_update_to</tt> but run asynchronously via a <tt>DurableStreams::BroadcastJob</tt>.
|
|
169
|
+
def stream_update_later_to(*streamables)
|
|
170
|
+
DurableStreams.broadcast_event_later_to(*streamables, **stream_event_attributes(operation: :update)) unless suppressed_streams?
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Same as <tt>#stream_update_later_to</tt>, but the designated stream is automatically set to the current model.
|
|
174
|
+
def stream_update_later
|
|
175
|
+
stream_update_later_to self
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Same as <tt>#stream_upsert_to</tt> but run asynchronously via a <tt>DurableStreams::BroadcastJob</tt>.
|
|
179
|
+
def stream_upsert_later_to(*streamables)
|
|
180
|
+
DurableStreams.broadcast_event_later_to(*streamables, **stream_event_attributes(operation: :upsert)) unless suppressed_streams?
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Same as <tt>#stream_upsert_later_to</tt>, but the designated stream is automatically set to the current model.
|
|
184
|
+
def stream_upsert_later
|
|
185
|
+
stream_upsert_later_to self
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Same as <tt>#stream_delete_to</tt> but run asynchronously via a <tt>DurableStreams::BroadcastJob</tt>.
|
|
189
|
+
def stream_delete_later_to(*streamables)
|
|
190
|
+
DurableStreams.broadcast_event_later_to(*streamables, **stream_event_attributes(operation: :delete, value: nil)) unless suppressed_streams?
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Same as <tt>#stream_delete_later_to</tt>, but the designated stream is automatically set to the current model.
|
|
194
|
+
def stream_delete_later
|
|
195
|
+
stream_delete_later_to self
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
private
|
|
199
|
+
def stream_event_attributes(operation:, value: as_json)
|
|
200
|
+
{ type: model_name.singular, key: id.to_s, value: value, operation: operation }
|
|
201
|
+
end
|
|
202
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
module DurableStreams
|
|
2
|
+
module Broadcastable
|
|
3
|
+
module TestHelper
|
|
4
|
+
extend ActiveSupport::Concern
|
|
5
|
+
|
|
6
|
+
included do
|
|
7
|
+
include DurableStreams::StreamName
|
|
8
|
+
|
|
9
|
+
setup { DurableStreams::Testing.install! }
|
|
10
|
+
teardown { DurableStreams::Testing.reset! }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Asserts that State Protocol events were broadcast to a stream
|
|
14
|
+
#
|
|
15
|
+
# ==== Arguments
|
|
16
|
+
#
|
|
17
|
+
# * <tt>stream_name_or_object</tt> the objects used to generate the
|
|
18
|
+
# stream name, or the name itself
|
|
19
|
+
# * <tt>&block</tt> optional block executed before the
|
|
20
|
+
# assertion
|
|
21
|
+
#
|
|
22
|
+
# ==== Options
|
|
23
|
+
#
|
|
24
|
+
# * <tt>count:</tt> the number of events that are expected to be broadcast
|
|
25
|
+
#
|
|
26
|
+
# Asserts events were broadcast:
|
|
27
|
+
#
|
|
28
|
+
# comment = Comment.find(1)
|
|
29
|
+
# comment.stream_insert_to comment.post
|
|
30
|
+
#
|
|
31
|
+
# assert_stream_broadcasts comment.post
|
|
32
|
+
#
|
|
33
|
+
# Asserts that two events were broadcast:
|
|
34
|
+
#
|
|
35
|
+
# comment = Comment.find(1)
|
|
36
|
+
# comment.stream_insert_to comment.post
|
|
37
|
+
# comment.stream_update_to comment.post
|
|
38
|
+
#
|
|
39
|
+
# assert_stream_broadcasts comment.post, count: 2
|
|
40
|
+
#
|
|
41
|
+
# You can pass a block to run before the assertion:
|
|
42
|
+
#
|
|
43
|
+
# comment = Comment.find(1)
|
|
44
|
+
#
|
|
45
|
+
# assert_stream_broadcasts comment.post do
|
|
46
|
+
# comment.stream_insert_to comment.post
|
|
47
|
+
# end
|
|
48
|
+
#
|
|
49
|
+
# In addition to a String, the helper also accepts an Object or Array to
|
|
50
|
+
# determine the stream name:
|
|
51
|
+
#
|
|
52
|
+
# post = Post.find(1)
|
|
53
|
+
#
|
|
54
|
+
# assert_stream_broadcasts post do
|
|
55
|
+
# post.comments.create!(body: "Hello")
|
|
56
|
+
# end
|
|
57
|
+
#
|
|
58
|
+
def assert_stream_broadcasts(stream_name_or_object, count: nil, &block)
|
|
59
|
+
payloads = capture_stream_broadcasts(stream_name_or_object, &block)
|
|
60
|
+
stream_name = stream_name_from(stream_name_or_object)
|
|
61
|
+
|
|
62
|
+
if count.nil?
|
|
63
|
+
assert_not_empty payloads, "Expected at least one broadcast on #{stream_name.inspect}, but there were none"
|
|
64
|
+
else
|
|
65
|
+
broadcasts = "Durable Stream broadcast".pluralize(count)
|
|
66
|
+
|
|
67
|
+
assert count == payloads.count, "Expected #{count} #{broadcasts} on #{stream_name.inspect}, but there were #{payloads.count}"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Asserts that no State Protocol events were broadcast to a stream
|
|
72
|
+
#
|
|
73
|
+
# ==== Arguments
|
|
74
|
+
#
|
|
75
|
+
# * <tt>stream_name_or_object</tt> the objects used to generate the
|
|
76
|
+
# stream name, or the name itself
|
|
77
|
+
# * <tt>&block</tt> optional block executed before the
|
|
78
|
+
# assertion
|
|
79
|
+
#
|
|
80
|
+
# Asserts that no events were broadcast:
|
|
81
|
+
#
|
|
82
|
+
# assert_no_stream_broadcasts "messages" do
|
|
83
|
+
# # do something other than broadcast to "messages"
|
|
84
|
+
# end
|
|
85
|
+
#
|
|
86
|
+
# In addition to a String, the helper also accepts an Object or Array to
|
|
87
|
+
# determine the stream name:
|
|
88
|
+
#
|
|
89
|
+
# post = Post.find(1)
|
|
90
|
+
#
|
|
91
|
+
# assert_no_stream_broadcasts post do
|
|
92
|
+
# # do something other than broadcast to post's stream
|
|
93
|
+
# end
|
|
94
|
+
#
|
|
95
|
+
def assert_no_stream_broadcasts(stream_name_or_object, &block)
|
|
96
|
+
block&.call
|
|
97
|
+
|
|
98
|
+
stream_name = stream_name_from(stream_name_or_object)
|
|
99
|
+
payloads = stream_broadcasts_for(stream_name)
|
|
100
|
+
|
|
101
|
+
assert payloads.empty?, "Expected no broadcasts on #{stream_name.inspect}, but there were #{payloads.count}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Captures any State Protocol events that were broadcast to a stream
|
|
105
|
+
#
|
|
106
|
+
# ==== Arguments
|
|
107
|
+
#
|
|
108
|
+
# * <tt>stream_name_or_object</tt> the objects used to generate the
|
|
109
|
+
# stream name, or the name itself
|
|
110
|
+
# * <tt>&block</tt> optional block to capture broadcasts during execution
|
|
111
|
+
#
|
|
112
|
+
# Returns any events that have been broadcast as an Array of parsed JSON hashes
|
|
113
|
+
#
|
|
114
|
+
# comment = Comment.find(1)
|
|
115
|
+
# comment.stream_insert_to comment.post
|
|
116
|
+
# comment.stream_update_to comment.post
|
|
117
|
+
#
|
|
118
|
+
# events = capture_stream_broadcasts comment.post
|
|
119
|
+
#
|
|
120
|
+
# assert_equal "insert", events.first["headers"]["operation"]
|
|
121
|
+
# assert_equal "update", events.second["headers"]["operation"]
|
|
122
|
+
#
|
|
123
|
+
# You can pass a block to limit the scope of the broadcasts being captured:
|
|
124
|
+
#
|
|
125
|
+
# comment = Comment.find(1)
|
|
126
|
+
#
|
|
127
|
+
# events = capture_stream_broadcasts comment.post do
|
|
128
|
+
# comment.stream_insert_to comment.post
|
|
129
|
+
# end
|
|
130
|
+
#
|
|
131
|
+
# assert_equal "insert", events.first["headers"]["operation"]
|
|
132
|
+
#
|
|
133
|
+
def capture_stream_broadcasts(stream_name_or_object, &block)
|
|
134
|
+
stream_name = stream_name_from(stream_name_or_object)
|
|
135
|
+
|
|
136
|
+
if block_given?
|
|
137
|
+
before = stream_broadcasts_for(stream_name).size
|
|
138
|
+
block.call
|
|
139
|
+
stream_broadcasts_for(stream_name)[before..]
|
|
140
|
+
else
|
|
141
|
+
stream_broadcasts_for(stream_name)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
def stream_broadcasts_for(stream_name)
|
|
147
|
+
DurableStreams::Testing.messages_for(stream_name).map do |message|
|
|
148
|
+
if message.is_a?(String)
|
|
149
|
+
JSON.parse(message)
|
|
150
|
+
else
|
|
151
|
+
message
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Provides the broadcast actions in synchronous and asynchronous form for the <tt>DurableStreams</tt> module.
|
|
2
|
+
# See <tt>DurableStreams::Broadcastable</tt> for the user-facing API that invokes these methods with most of the
|
|
3
|
+
# paperwork filled out already.
|
|
4
|
+
#
|
|
5
|
+
# Can be used directly using something like <tt>DurableStreams.broadcast_event_to :room, type: "message", key: "5",
|
|
6
|
+
# value: { content: "Hello" }, operation: :insert</tt>.
|
|
7
|
+
module DurableStreams::Broadcasts
|
|
8
|
+
def broadcast_event_to(*streamables, type:, key:, value:, operation:)
|
|
9
|
+
streamables.flatten!
|
|
10
|
+
streamables.compact_blank!
|
|
11
|
+
|
|
12
|
+
if streamables.present?
|
|
13
|
+
txid = SecureRandom.uuid
|
|
14
|
+
|
|
15
|
+
append_to_stream(
|
|
16
|
+
stream_name_from(streamables),
|
|
17
|
+
JSON.generate({
|
|
18
|
+
type: type, key: key, value: value,
|
|
19
|
+
headers: { operation: operation, txid: txid }
|
|
20
|
+
})
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
txid
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def broadcast_event_later_to(*streamables, type:, key:, value:, operation:)
|
|
28
|
+
streamables.flatten!
|
|
29
|
+
streamables.compact_blank!
|
|
30
|
+
|
|
31
|
+
if streamables.present?
|
|
32
|
+
DurableStreams::BroadcastJob.perform_later \
|
|
33
|
+
stream_name_from(streamables),
|
|
34
|
+
type: type, key: key, value: value, operation: operation.to_s
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def broadcast_to(*streamables, **payload)
|
|
39
|
+
streamables.flatten!
|
|
40
|
+
streamables.compact_blank!
|
|
41
|
+
|
|
42
|
+
if streamables.present?
|
|
43
|
+
append_to_stream(stream_name_from(streamables), JSON.generate(payload))
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
def append_to_stream(stream_name, message)
|
|
49
|
+
if DurableStreams::Testing.recording?
|
|
50
|
+
DurableStreams::Testing.record(stream_name, message)
|
|
51
|
+
else
|
|
52
|
+
stream(stream_name).append(message)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
require "rails/engine"
|
|
2
|
+
|
|
3
|
+
module DurableStreams
|
|
4
|
+
class Engine < Rails::Engine
|
|
5
|
+
isolate_namespace DurableStreams
|
|
6
|
+
config.eager_load_namespaces << DurableStreams
|
|
7
|
+
config.durable_streams = ActiveSupport::OrderedOptions.new
|
|
8
|
+
config.autoload_once_paths = %W(
|
|
9
|
+
#{root}/app/models
|
|
10
|
+
#{root}/app/models/concerns
|
|
11
|
+
#{root}/app/jobs
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
initializer "durable_streams.no_active_job", before: :set_eager_load_paths do
|
|
15
|
+
unless defined?(ActiveJob)
|
|
16
|
+
Rails.autoloaders.once.do_not_eager_load("#{root}/app/jobs")
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
initializer "durable_streams.broadcastable" do
|
|
21
|
+
ActiveSupport.on_load(:active_record) do
|
|
22
|
+
if defined?(ActiveJob)
|
|
23
|
+
include DurableStreams::Broadcastable
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
initializer "durable_streams.signed_stream_verifier_key" do
|
|
29
|
+
config.after_initialize do
|
|
30
|
+
DurableStreams.signed_stream_verifier_key =
|
|
31
|
+
config.durable_streams&.signed_stream_verifier_key ||
|
|
32
|
+
Rails.application.key_generator.generate_key("durable_streams/signed_stream_verifier_key")
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
initializer "durable_streams.test_assertions" do
|
|
37
|
+
ActiveSupport.on_load(:active_support_test_case) do
|
|
38
|
+
if defined?(ActiveJob)
|
|
39
|
+
require "durable_streams/broadcastable/test_helper"
|
|
40
|
+
include DurableStreams::Broadcastable::TestHelper
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Stream names are how we identify which updates should go to which subscribers. Since stream names
|
|
2
|
+
# are exposed directly to the client via signed URL tokens, we need to ensure that the name isn't
|
|
3
|
+
# tampered with, so the names are signed upon generation and verified upon receipt. All verification
|
|
4
|
+
# happens through the <tt>DurableStreams.signed_stream_verifier</tt>.
|
|
5
|
+
module DurableStreams::StreamName
|
|
6
|
+
# Used by the stream auth endpoint to verify a signed stream name.
|
|
7
|
+
def verified_stream_name(signed_stream_name)
|
|
8
|
+
DurableStreams.signed_stream_verifier.verified signed_stream_name
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Used by <tt>DurableStreams.signed_stream_url</tt> to generate a signed stream name.
|
|
12
|
+
def signed_stream_name(streamables)
|
|
13
|
+
DurableStreams.signed_stream_verifier.generate stream_name_from(streamables)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
def stream_name_from(streamables)
|
|
18
|
+
if streamables.is_a?(Array)
|
|
19
|
+
streamables.map { |streamable| stream_name_from(streamable) }.join("/")
|
|
20
|
+
else
|
|
21
|
+
streamables.then { |streamable| streamable.try(:to_gid_param) || streamable.to_param }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
require "active_support/core_ext/module/attribute_accessors_per_thread"
|
|
2
|
+
|
|
3
|
+
# Intercepts stream appends during tests, similar to how <tt>ActionCable::TestHelper</tt> captures broadcasts.
|
|
4
|
+
# Activated by <tt>DurableStreams::Broadcastable::TestHelper</tt> via +setup+/+teardown+ hooks.
|
|
5
|
+
#
|
|
6
|
+
# When installed, all calls to <tt>DurableStreams.broadcast_event_to</tt> and <tt>DurableStreams.broadcast_to</tt>
|
|
7
|
+
# are recorded in memory instead of being sent to the stream server. Captured messages can be inspected via
|
|
8
|
+
# <tt>messages_for</tt>.
|
|
9
|
+
module DurableStreams::Testing
|
|
10
|
+
thread_mattr_accessor :messages
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
def install!
|
|
14
|
+
self.messages = Hash.new { |h, k| h[k] = [] }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def reset!
|
|
18
|
+
self.messages = nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def recording?
|
|
22
|
+
messages.present?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def record(stream_name, message)
|
|
26
|
+
messages[stream_name] << message if recording?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def messages_for(stream_name)
|
|
30
|
+
(messages || {})[stream_name] || []
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
require "durable_streams/stream_name"
|
|
2
|
+
require "durable_streams/broadcasts"
|
|
3
|
+
require "durable_streams/testing"
|
|
4
|
+
require "durable_streams/engine"
|
|
5
|
+
require "active_support/core_ext/module/attribute_accessors_per_thread"
|
|
6
|
+
|
|
7
|
+
module DurableStreams
|
|
8
|
+
extend DurableStreams::StreamName
|
|
9
|
+
extend DurableStreams::Broadcasts
|
|
10
|
+
|
|
11
|
+
mattr_accessor :base_url
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
attr_writer :signed_stream_verifier_key
|
|
15
|
+
|
|
16
|
+
def signed_stream_verifier
|
|
17
|
+
@signed_stream_verifier ||= ActiveSupport::MessageVerifier.new(signed_stream_verifier_key, digest: "SHA256", serializer: JSON)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def signed_stream_verifier_key
|
|
21
|
+
@signed_stream_verifier_key or raise ArgumentError, "DurableStreams requires a signed_stream_verifier_key"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def signed_stream_url(*streamables, expires_in: 24.hours)
|
|
25
|
+
path = stream_name_from(streamables)
|
|
26
|
+
token = signed_stream_verifier.generate(path, expires_in: expires_in)
|
|
27
|
+
"#{base_url}/#{path}?token=#{token}"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: durable_streams-rails
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- tokimonki
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: durable_streams
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.1'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0.1'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: railties
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '7.1'
|
|
33
|
+
- - ">="
|
|
34
|
+
- !ruby/object:Gem::Version
|
|
35
|
+
version: 7.1.0
|
|
36
|
+
type: :runtime
|
|
37
|
+
prerelease: false
|
|
38
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
39
|
+
requirements:
|
|
40
|
+
- - "~>"
|
|
41
|
+
- !ruby/object:Gem::Version
|
|
42
|
+
version: '7.1'
|
|
43
|
+
- - ">="
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: 7.1.0
|
|
46
|
+
email: opensource@tokimonki.com
|
|
47
|
+
executables: []
|
|
48
|
+
extensions: []
|
|
49
|
+
extra_rdoc_files: []
|
|
50
|
+
files:
|
|
51
|
+
- MIT-LICENSE
|
|
52
|
+
- Rakefile
|
|
53
|
+
- app/jobs/durable_streams/broadcast_job.rb
|
|
54
|
+
- app/models/concerns/durable_streams/broadcastable.rb
|
|
55
|
+
- lib/durable_streams-rails.rb
|
|
56
|
+
- lib/durable_streams/broadcastable/test_helper.rb
|
|
57
|
+
- lib/durable_streams/broadcasts.rb
|
|
58
|
+
- lib/durable_streams/engine.rb
|
|
59
|
+
- lib/durable_streams/stream_name.rb
|
|
60
|
+
- lib/durable_streams/testing.rb
|
|
61
|
+
- lib/durable_streams/version.rb
|
|
62
|
+
homepage: https://github.com/tokimonki/durable_streams-rails
|
|
63
|
+
licenses:
|
|
64
|
+
- MIT
|
|
65
|
+
metadata: {}
|
|
66
|
+
rdoc_options: []
|
|
67
|
+
require_paths:
|
|
68
|
+
- lib
|
|
69
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
70
|
+
requirements:
|
|
71
|
+
- - ">="
|
|
72
|
+
- !ruby/object:Gem::Version
|
|
73
|
+
version: '3.1'
|
|
74
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
75
|
+
requirements:
|
|
76
|
+
- - ">="
|
|
77
|
+
- !ruby/object:Gem::Version
|
|
78
|
+
version: '0'
|
|
79
|
+
requirements: []
|
|
80
|
+
rubygems_version: 3.6.9
|
|
81
|
+
specification_version: 4
|
|
82
|
+
summary: Durable Streams integration for Rails
|
|
83
|
+
test_files: []
|