openui-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.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/AUDIT.md +102 -0
  3. data/CHANGELOG.md +71 -0
  4. data/CODE_OF_CONDUCT.md +37 -0
  5. data/CONFIGURATION.md +79 -0
  6. data/CONTRIBUTING.md +66 -0
  7. data/LICENSE +22 -0
  8. data/README.md +471 -0
  9. data/RELEASING.md +90 -0
  10. data/SECURITY.md +48 -0
  11. data/THIRD_PARTY_NOTICES.md +45 -0
  12. data/UPGRADING.md +26 -0
  13. data/app/controllers/openui/rails/actions_controller.rb +77 -0
  14. data/app/controllers/openui/rails/application_controller.rb +10 -0
  15. data/app/javascript/openui/rails/openui_controller.js +347 -0
  16. data/app/javascript/openui/rails/openui_envelope_controller.js +38 -0
  17. data/config/routes.rb +5 -0
  18. data/lib/generators/openui/install/install_generator.rb +62 -0
  19. data/lib/generators/openui/install/templates/config/initializers/openui.rb.tt +30 -0
  20. data/lib/generators/openui/install/templates/config/openui/README.md +24 -0
  21. data/lib/openui/rails/actions/dispatcher.rb +194 -0
  22. data/lib/openui/rails/actions/idempotency/memory_store.rb +64 -0
  23. data/lib/openui/rails/actions/invocation.rb +16 -0
  24. data/lib/openui/rails/actions/registry.rb +113 -0
  25. data/lib/openui/rails/actions/schema.rb +115 -0
  26. data/lib/openui/rails/actions/submission.rb +102 -0
  27. data/lib/openui/rails/actions.rb +18 -0
  28. data/lib/openui/rails/adapters/ruby_llm.rb +113 -0
  29. data/lib/openui/rails/configuration.rb +282 -0
  30. data/lib/openui/rails/engine.rb +46 -0
  31. data/lib/openui/rails/errors.rb +41 -0
  32. data/lib/openui/rails/json_value.rb +33 -0
  33. data/lib/openui/rails/library_manifest.rb +234 -0
  34. data/lib/openui/rails/message.rb +139 -0
  35. data/lib/openui/rails/prompt_builder.rb +115 -0
  36. data/lib/openui/rails/runtime_asset.rb +65 -0
  37. data/lib/openui/rails/runtime_package.rb +74 -0
  38. data/lib/openui/rails/stream/accumulator.rb +86 -0
  39. data/lib/openui/rails/stream/bridge.rb +193 -0
  40. data/lib/openui/rails/stream/broadcaster.rb +106 -0
  41. data/lib/openui/rails/stream/envelope.rb +159 -0
  42. data/lib/openui/rails/stream/transports/action_cable.rb +50 -0
  43. data/lib/openui/rails/stream/transports/memory.rb +33 -0
  44. data/lib/openui/rails/stream/transports/turbo_streams.rb +61 -0
  45. data/lib/openui/rails/stream/transports.rb +41 -0
  46. data/lib/openui/rails/surface.rb +150 -0
  47. data/lib/openui/rails/version.rb +5 -0
  48. data/lib/openui/rails/view_helper.rb +56 -0
  49. data/lib/openui/rails.rb +33 -0
  50. data/lib/tasks/openui.rake +16 -0
  51. metadata +125 -0
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenUI
4
+ module Rails
5
+ module Stream
6
+ # Client-side (Ruby) sequence accumulator mirroring what the browser must
7
+ # do: apply ordered deltas, ignore duplicates, and treat a missing
8
+ # sequence as "fetch the authoritative snapshot" rather than executing a
9
+ # malformed partial. Used by server tests and by hosts that consume their
10
+ # own stream.
11
+ class Accumulator
12
+ attr_reader :message_id, :source
13
+
14
+ def initialize(message_id:)
15
+ @message_id = message_id
16
+ @source = +""
17
+ @next_sequence = 0
18
+ @completed = false
19
+ @error = nil
20
+ @needs_snapshot = false
21
+ end
22
+
23
+ # @return [Symbol] one of :applied, :duplicate, :snapshot, :completed,
24
+ # :error, :ignored
25
+ def apply(envelope)
26
+ return :ignored unless envelope.message_id == message_id
27
+ unless envelope.snapshot?
28
+ return :duplicate if envelope.sequence < @next_sequence
29
+ return :ignored if @completed || error?
30
+ return :gap if @needs_snapshot
31
+ if envelope.sequence > @next_sequence
32
+ @needs_snapshot = true
33
+ return :gap
34
+ end
35
+ end
36
+
37
+ case envelope.type
38
+ when Envelope::DELTA then apply_delta(envelope)
39
+ when Envelope::SNAPSHOT then apply_snapshot(envelope)
40
+ when Envelope::COMPLETED then apply_completed(envelope)
41
+ when Envelope::ERROR then apply_error(envelope)
42
+ else :ignored
43
+ end
44
+ end
45
+
46
+ def next_sequence = @next_sequence
47
+ def completed? = @completed
48
+ def error? = !@error.nil?
49
+ def needs_snapshot? = @needs_snapshot
50
+ def error = @error
51
+
52
+ private
53
+
54
+ def apply_delta(envelope)
55
+ @source << envelope.delta
56
+ @next_sequence += 1
57
+ :applied
58
+ end
59
+
60
+ def apply_snapshot(envelope)
61
+ minimum = @needs_snapshot ? @next_sequence - 1 : @next_sequence
62
+ return :duplicate if envelope.sequence < minimum
63
+
64
+ @source = +envelope.response.dup
65
+ @next_sequence = envelope.sequence + 1
66
+ @needs_snapshot = false
67
+ @completed = !envelope.streaming?
68
+ @error = nil
69
+ :snapshot
70
+ end
71
+
72
+ def apply_completed(envelope)
73
+ @completed = true
74
+ @next_sequence = [ @next_sequence, envelope.sequence + 1 ].max
75
+ :completed
76
+ end
77
+
78
+ def apply_error(envelope)
79
+ @error = envelope
80
+ @next_sequence += 1
81
+ :error
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenUI
4
+ module Rails
5
+ module Stream
6
+ # Turns model output into an ordered sequence of {Envelope}s, coalescing
7
+ # small chunks up to `pending_byte_limit` and enforcing `stream_byte_limit`.
8
+ # Transport-agnostic: a caller broadcasts the yielded envelopes over Turbo
9
+ # Streams or Action Cable.
10
+ #
11
+ # Pull sources (an Enumerable of chunks) go through {#each_envelope};
12
+ # push sources (a streaming callback) use a {Writer} directly.
13
+ class Bridge
14
+ attr_reader :message_id, :library_version
15
+
16
+ # Create a bridge with message metadata and configured byte limits.
17
+ #
18
+ # @param message_id [String] stable id shared by all frames in the stream
19
+ #
20
+ # @param library_version [String, nil] manifest fingerprint attached to frames
21
+ #
22
+ # @param configuration [Configuration]
23
+ #
24
+ # @return [Bridge]
25
+ def initialize(message_id:, library_version: nil, configuration: OpenUI::Rails.configuration)
26
+ @message_id = message_id
27
+ @library_version = library_version
28
+ @configuration = configuration
29
+ end
30
+
31
+ # @param source [#each] yields String chunks
32
+ #
33
+ # @return [Enumerator<Envelope>]
34
+ def envelopes(source)
35
+ to_enum(:each_envelope, source)
36
+ end
37
+
38
+ # Yield ordered envelopes for every chunk from a pull-based source.
39
+ # Provider errors become a structured error envelope. Exceptions raised
40
+ # by the consumer block propagate to the caller.
41
+ #
42
+ # @param source [#each] yields String chunks
43
+ # @yieldparam envelope [Envelope]
44
+ def each_envelope(source)
45
+ writer = writer()
46
+ source = source.to_enum
47
+ loop do
48
+ begin
49
+ chunk = source.next
50
+ envelopes = writer.push(chunk)
51
+ rescue StopIteration
52
+ break
53
+ rescue StandardError => error
54
+ envelope = writer.error_for(error)
55
+ yield envelope if envelope
56
+ return
57
+ end
58
+ # Transport/consumer failures are not provider failures. Let the
59
+ # host retry or report them without recursively broadcasting errors.
60
+ envelopes.each { |envelope| yield envelope }
61
+ end
62
+ writer.finish.each { |envelope| yield envelope }
63
+ end
64
+
65
+ # Build a stateful writer for push-based providers.
66
+ #
67
+ # @return [Writer]
68
+ def writer
69
+ Writer.new(
70
+ message_id: message_id,
71
+ library_version: library_version,
72
+ pending_byte_limit: @configuration.pending_byte_limit,
73
+ stream_byte_limit: @configuration.stream_byte_limit,
74
+ on_error: @configuration.on_error
75
+ )
76
+ end
77
+
78
+ # Accumulates pushed chunks and emits envelopes. Stateful across calls.
79
+ class Writer
80
+ attr_reader :sequence
81
+
82
+ # Create a writer that buffers chunks up to the pending byte limit.
83
+ #
84
+ # @param message_id [String]
85
+ #
86
+ # @param library_version [String, nil]
87
+ #
88
+ # @param pending_byte_limit [Integer]
89
+ #
90
+ # @param stream_byte_limit [Integer]
91
+ #
92
+ # @param on_error [#call, nil]
93
+ #
94
+ # @return [Writer]
95
+ def initialize(message_id:, library_version: nil, pending_byte_limit:, stream_byte_limit:, on_error: nil)
96
+ Envelope.completed(message_id: message_id, sequence: 0, library_version: library_version)
97
+ unless pending_byte_limit.is_a?(Integer) && stream_byte_limit.is_a?(Integer) &&
98
+ pending_byte_limit > 0 && stream_byte_limit >= pending_byte_limit
99
+ raise ConfigurationError, "stream limits must be positive integers with pending <= total"
100
+ end
101
+ @message_id = message_id
102
+ @library_version = library_version
103
+ @pending_byte_limit = pending_byte_limit
104
+ @stream_byte_limit = stream_byte_limit
105
+ @on_error = on_error
106
+ @sequence = 0
107
+ @total_bytes = 0
108
+ @pending = +""
109
+ @terminated = false
110
+ end
111
+
112
+ # Append a chunk and return a delta once the pending threshold is reached.
113
+ # A chunk that exceeds the total stream limit returns an error envelope.
114
+ #
115
+ # @param chunk [String, nil]
116
+ #
117
+ # @return [Array<Envelope>] zero or more envelopes to emit
118
+ def push(chunk)
119
+ return [] if @terminated || chunk.nil?
120
+ raise StreamError, "stream chunks must be strings" unless chunk.is_a?(String)
121
+ return [] if chunk.empty?
122
+
123
+ @total_bytes += chunk.bytesize
124
+ if @total_bytes > @stream_byte_limit
125
+ return [ error_envelope("stream_too_large", "stream exceeded #{@stream_byte_limit} bytes") ]
126
+ end
127
+
128
+ @pending << chunk
129
+ return [] if @pending.bytesize < @pending_byte_limit
130
+
131
+ [ flush_delta ]
132
+ end
133
+
134
+ # Flush pending text and emit the completion envelope once.
135
+ #
136
+ # @return [Array<Envelope>] the final delta, if any, and completion
137
+ def finish
138
+ return [] if @terminated
139
+
140
+ envelopes = []
141
+ envelopes << flush_delta unless @pending.empty?
142
+ envelopes << Envelope.completed(
143
+ message_id: @message_id, sequence: @sequence, library_version: @library_version
144
+ )
145
+ @sequence += 1
146
+ @terminated = true
147
+ envelopes
148
+ end
149
+
150
+ # Structured, PII-free error envelope for a provider/consumer failure.
151
+ #
152
+ # @param error [Exception]
153
+ #
154
+ # @param code [String] a supported stream error code
155
+ #
156
+ # @return [Envelope, nil] nil when the writer already terminated
157
+ def error_for(error, code: "stream_failed")
158
+ return nil if @terminated
159
+
160
+ @on_error&.call(error, message_id: @message_id)
161
+
162
+ message = "stream failed (#{error.class})"
163
+ message = "stream cancelled" if code == "stream_cancelled"
164
+ error_envelope(code, message)
165
+ end
166
+
167
+ private
168
+
169
+ def flush_delta
170
+ envelope = Envelope.delta(
171
+ message_id: @message_id, sequence: @sequence, delta: @pending.dup,
172
+ library_version: @library_version, streaming: true
173
+ )
174
+ @sequence += 1
175
+ @pending = +""
176
+ envelope
177
+ end
178
+
179
+ def error_envelope(code, message)
180
+ envelope = Envelope.error(
181
+ message_id: @message_id, sequence: @sequence, library_version: @library_version,
182
+ code: code, message: message
183
+ )
184
+ @sequence += 1
185
+ @terminated = true
186
+ @pending.clear
187
+ envelope
188
+ end
189
+ end
190
+ end
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenUI
4
+ module Rails
5
+ module Stream
6
+ # Binds the ordered {Bridge} to a transport and broadcasts every envelope
7
+ # as it is produced.
8
+ #
9
+ # The transport is a seam: any object that responds to
10
+ # `#broadcast(envelope)` works, and {Transports} ships wrappers for Action
11
+ # Cable, Turbo Streams and an in-memory collector. Delivery is best effort:
12
+ # Action Cable and Turbo Streams do not promise order or delivery. The
13
+ # client applies sequence numbers and recovers from an
14
+ # authoritative snapshot on a gap (see the Stimulus controller).
15
+ #
16
+ # broadcaster = Broadcaster.new(
17
+ # transport: Transports::ActionCable.new("openui:surface:42"),
18
+ # message_id: "msg-1",
19
+ # library_version: OpenUI::Rails.configuration.library_fingerprint
20
+ # )
21
+ # chat.complete { |chunk| broadcaster.push(chunk.content) }
22
+ # broadcaster.finish
23
+ class Broadcaster
24
+ attr_reader :transport, :message_id, :library_version
25
+
26
+ # Broadcast every envelope produced by an enumerable source of chunks.
27
+ #
28
+ # @param source [#each] yields String chunks
29
+ # @param options [Hash] arguments forwarded to the constructor
30
+ #
31
+ # @return [Broadcaster]
32
+ def self.broadcast(source, **options)
33
+ broadcaster = new(**options)
34
+ source.each { |chunk| broadcaster.push(chunk) }
35
+ broadcaster.finish
36
+ end
37
+
38
+ # Bind a bridge writer to a transport that responds to `broadcast`.
39
+ #
40
+ # @param transport [#broadcast]
41
+ #
42
+ # @param message_id [String]
43
+ #
44
+ # @param library_version [String, nil]
45
+ #
46
+ # @param configuration [Configuration]
47
+ #
48
+ # @return [Broadcaster]
49
+ def initialize(transport:, message_id:, library_version: nil, configuration: OpenUI::Rails.configuration)
50
+ raise ConfigurationError, "a transport is required" if transport.nil?
51
+ unless transport.respond_to?(:broadcast)
52
+ raise ConfigurationError, "transport must respond to #broadcast"
53
+ end
54
+
55
+ @transport = transport
56
+ @message_id = message_id
57
+ @library_version = library_version
58
+ @configuration = configuration
59
+ @writer = Bridge.new(
60
+ message_id: message_id,
61
+ library_version: library_version,
62
+ configuration: configuration
63
+ ).writer
64
+ end
65
+
66
+ # Push a chunk; broadcasts any envelopes the writer flushes.
67
+ #
68
+ # @param chunk [String, nil]
69
+ #
70
+ # @return [self]
71
+ def push(chunk)
72
+ @writer.push(chunk).each { |envelope| @transport.broadcast(envelope) }
73
+ self
74
+ end
75
+
76
+ # Broadcast the final delta (if any) and the completion marker.
77
+ #
78
+ # @return [self]
79
+ def finish
80
+ @writer.finish.each { |envelope| @transport.broadcast(envelope) }
81
+ self
82
+ end
83
+
84
+ # Broadcast a structured, PII-free error envelope and stop.
85
+ #
86
+ # @param error [Exception]
87
+ #
88
+ # @param code [String]
89
+ #
90
+ # @return [self]
91
+ def fail(error, code: "stream_failed")
92
+ envelope = @writer.error_for(error, code: code)
93
+ @transport.broadcast(envelope) if envelope
94
+ self
95
+ end
96
+
97
+ # The next sequence number the writer will assign.
98
+ #
99
+ def sequence = @writer.sequence
100
+
101
+ # The underlying writer, for callers that need finer control.
102
+ attr_reader :writer
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module OpenUI
6
+ module Rails
7
+ module Stream
8
+ # A single, gem-defined stream frame. This is *not* an OpenUI protocol:
9
+ # it is the wire shape the Gem normalizes chunks into over Turbo Streams
10
+ # or Action Cable. Ordering is per message and starts at sequence 0.
11
+ #
12
+ # Payloads never carry tools, credentials or server secrets.
13
+ class Envelope
14
+ DELTA = "openui.delta"
15
+ SNAPSHOT = "openui.snapshot"
16
+ COMPLETED = "openui.completed"
17
+ ERROR = "openui.error"
18
+ TYPES = [ DELTA, SNAPSHOT, COMPLETED, ERROR ].freeze
19
+
20
+ MAX_MESSAGE_ID_BYTES = 200
21
+ ERROR_CODES = %w[
22
+ stream_too_large
23
+ stream_failed
24
+ stream_cancelled
25
+ provider_rejected
26
+ library_version_mismatch
27
+ renderer_error
28
+ ].freeze
29
+
30
+ attr_reader :message_id, :sequence, :type, :library_version, :data
31
+
32
+ def initialize(type:, message_id:, sequence:, library_version: nil, data: {})
33
+ @type = validate_type(type).dup.freeze
34
+ @message_id = validate_message_id(message_id).dup.freeze
35
+ @sequence = validate_sequence(sequence)
36
+ unless library_version.nil? || library_version.is_a?(String)
37
+ raise StreamError, "library_version must be a String"
38
+ end
39
+ @library_version = library_version&.dup&.freeze
40
+ unless data.is_a?(Hash) && (data.keys.map(&:to_s) & %w[type message_id sequence library_version]).empty?
41
+ raise StreamError, "envelope data cannot override routing metadata"
42
+ end
43
+ @data = JsonValue.copy(data)
44
+ validate_data!
45
+ freeze
46
+ end
47
+
48
+ class << self
49
+ def delta(message_id:, sequence:, delta:, library_version: nil, streaming: true)
50
+ validate_string!(delta, "delta")
51
+ new(
52
+ type: DELTA, message_id: message_id, sequence: sequence, library_version: library_version,
53
+ data: { "delta" => delta, "streaming" => streaming }
54
+ )
55
+ end
56
+
57
+ def snapshot(message_id:, sequence:, response:, library_version: nil, streaming: false)
58
+ validate_string!(response, "response")
59
+ new(
60
+ type: SNAPSHOT, message_id: message_id, sequence: sequence, library_version: library_version,
61
+ data: { "response" => response, "streaming" => streaming }
62
+ )
63
+ end
64
+
65
+ def completed(message_id:, sequence:, library_version: nil)
66
+ new(
67
+ type: COMPLETED, message_id: message_id, sequence: sequence, library_version: library_version,
68
+ data: { "streaming" => false }
69
+ )
70
+ end
71
+
72
+ def error(message_id:, sequence:, code:, message: nil, library_version: nil)
73
+ code = code.to_s
74
+ unless ERROR_CODES.include?(code)
75
+ raise StreamError, "unknown error code #{code.inspect}; expected one of #{ERROR_CODES.inspect}"
76
+ end
77
+
78
+ new(
79
+ type: ERROR, message_id: message_id, sequence: sequence, library_version: library_version,
80
+ data: { "code" => code, "message" => message, "streaming" => false }.compact
81
+ )
82
+ end
83
+
84
+ def validate_string!(value, name)
85
+ return if value.is_a?(String)
86
+
87
+ raise StreamError, "#{name} must be a String"
88
+ end
89
+ end
90
+
91
+ def delta = data["delta"]
92
+ def response = data["response"]
93
+ def code = data["code"]
94
+ def streaming? = data["streaming"] == true
95
+ def delta? = type == DELTA
96
+ def snapshot? = type == SNAPSHOT
97
+ def completed? = type == COMPLETED
98
+ def error? = type == ERROR
99
+
100
+ def to_h
101
+ hash = { "message_id" => message_id, "sequence" => sequence, "type" => type }
102
+ hash["library_version"] = library_version if library_version
103
+ hash.merge!(data)
104
+ hash
105
+ end
106
+
107
+ def to_json(*) = JSON.generate(to_h)
108
+
109
+ def ==(other)
110
+ other.is_a?(Envelope) && other.to_h == to_h
111
+ end
112
+ alias eql? ==
113
+
114
+ def hash = to_h.hash
115
+
116
+ private
117
+
118
+ def validate_data!
119
+ case type
120
+ when DELTA then self.class.validate_string!(delta, "delta")
121
+ when SNAPSHOT then self.class.validate_string!(response, "response")
122
+ when ERROR
123
+ raise StreamError, "unknown error code #{code.inspect}" unless ERROR_CODES.include?(code)
124
+ end
125
+ if data.key?("streaming") && ![ true, false ].include?(data["streaming"])
126
+ raise StreamError, "streaming must be boolean"
127
+ end
128
+ end
129
+
130
+ def validate_type(type)
131
+ type = type.to_s
132
+ raise StreamError, "unknown envelope type #{type.inspect}" unless TYPES.include?(type)
133
+
134
+ type
135
+ end
136
+
137
+ def validate_message_id(message_id)
138
+ unless message_id.is_a?(String) && !message_id.empty?
139
+ raise StreamError, "message_id must be a non-empty String"
140
+ end
141
+
142
+ if message_id.bytesize > MAX_MESSAGE_ID_BYTES
143
+ raise StreamError, "message_id exceeds #{MAX_MESSAGE_ID_BYTES} bytes"
144
+ end
145
+
146
+ message_id
147
+ end
148
+
149
+ def validate_sequence(sequence)
150
+ unless sequence.is_a?(Integer) && sequence >= 0
151
+ raise StreamError, "sequence must be a non-negative Integer"
152
+ end
153
+
154
+ sequence
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenUI
4
+ module Rails
5
+ module Stream
6
+ module Transports
7
+ # Broadcasts envelope hashes over Action Cable.
8
+ #
9
+ # The stream name is supplied by the host (the Gem does not invent a
10
+ # tenant-scoping scheme). Pair it with a channel that dispatches each
11
+ # received payload as an `openui:envelope` event on the page. See the
12
+ # "Transports" section of the README. Authorization for the subscription
13
+ # stays in the host's channel.
14
+ #
15
+ # `cable:` is the Action Cable server (or a test double that responds to
16
+ # `#broadcast`), injectable so this can be exercised without a running
17
+ # Action Cable server.
18
+ class ActionCable
19
+ attr_reader :stream_name
20
+
21
+ def initialize(stream_name, cable: nil)
22
+ if stream_name.nil? || stream_name.to_s.empty?
23
+ raise ConfigurationError, "a stream_name is required"
24
+ end
25
+
26
+ @stream_name = stream_name.to_s
27
+ @cable = cable
28
+ end
29
+
30
+ # @return [Object] the result of the underlying broadcast
31
+ def broadcast(envelope)
32
+ server.broadcast(stream_name, envelope.to_h)
33
+ end
34
+
35
+ private
36
+
37
+ def server
38
+ return @cable if @cable
39
+
40
+ unless defined?(::ActionCable) && ::ActionCable.respond_to?(:server)
41
+ raise StreamError, "Action Cable is not loaded; add actioncable or use another transport"
42
+ end
43
+
44
+ ::ActionCable.server
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenUI
4
+ module Rails
5
+ module Stream
6
+ module Transports
7
+ # In-memory transport for tests and examples. It keeps every broadcast
8
+ # envelope, so a caller can assert on the exact ordered sequence.
9
+ class Memory
10
+ attr_reader :envelopes
11
+
12
+ def initialize
13
+ @envelopes = []
14
+ end
15
+
16
+ def broadcast(envelope)
17
+ @envelopes << envelope
18
+ envelope
19
+ end
20
+
21
+ def to_a = @envelopes.dup
22
+
23
+ def clear
24
+ @envelopes.clear
25
+ self
26
+ end
27
+
28
+ def empty? = @envelopes.empty?
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require "json"
5
+
6
+ module OpenUI
7
+ module Rails
8
+ module Stream
9
+ module Transports
10
+ # Broadcasts each envelope as a Turbo Stream that appends an
11
+ # `openui-envelope` element to a target already in the DOM.
12
+ #
13
+ # The small `openui-envelope` Stimulus controller turns that element
14
+ # into an `openui:envelope` event and removes itself, so no inline
15
+ # script is used and no Content Security Policy relaxation is needed.
16
+ #
17
+ # turbo-rails is looked up lazily and may be injected via `turbo:`; the
18
+ # Gem never hard-depends on it.
19
+ class TurboStreams
20
+ ELEMENT_TAG = "div"
21
+
22
+ attr_reader :stream, :target
23
+
24
+ def initialize(stream, target:, turbo: nil)
25
+ raise ConfigurationError, "a stream is required" if stream.nil?
26
+ if target.nil? || target.to_s.empty?
27
+ raise ConfigurationError, "a target is required"
28
+ end
29
+
30
+ @stream = stream
31
+ @target = target.to_s
32
+ @turbo = turbo
33
+ end
34
+
35
+ def broadcast(envelope)
36
+ channel.broadcast_append_to(stream, target: target, content: element(envelope))
37
+ end
38
+
39
+ # The HTML fragment a Turbo Stream appends. Kept public so tests can
40
+ # assert on escaping without a Turbo dependency.
41
+ def element(envelope)
42
+ payload = ERB::Util.html_escape(JSON.generate(envelope.to_h))
43
+ %(<#{ELEMENT_TAG} data-controller="openui-envelope" data-openui-envelope-payload="#{payload}"></#{ELEMENT_TAG}>)
44
+ end
45
+
46
+ private
47
+
48
+ def channel
49
+ return @turbo if @turbo
50
+
51
+ unless defined?(::Turbo::StreamsChannel)
52
+ raise StreamError, "Turbo Streams is not loaded; add turbo-rails or use another transport"
53
+ end
54
+
55
+ ::Turbo::StreamsChannel
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end