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.
- checksums.yaml +7 -0
- data/AUDIT.md +102 -0
- data/CHANGELOG.md +71 -0
- data/CODE_OF_CONDUCT.md +37 -0
- data/CONFIGURATION.md +79 -0
- data/CONTRIBUTING.md +66 -0
- data/LICENSE +22 -0
- data/README.md +471 -0
- data/RELEASING.md +90 -0
- data/SECURITY.md +48 -0
- data/THIRD_PARTY_NOTICES.md +45 -0
- data/UPGRADING.md +26 -0
- data/app/controllers/openui/rails/actions_controller.rb +77 -0
- data/app/controllers/openui/rails/application_controller.rb +10 -0
- data/app/javascript/openui/rails/openui_controller.js +347 -0
- data/app/javascript/openui/rails/openui_envelope_controller.js +38 -0
- data/config/routes.rb +5 -0
- data/lib/generators/openui/install/install_generator.rb +62 -0
- data/lib/generators/openui/install/templates/config/initializers/openui.rb.tt +30 -0
- data/lib/generators/openui/install/templates/config/openui/README.md +24 -0
- data/lib/openui/rails/actions/dispatcher.rb +194 -0
- data/lib/openui/rails/actions/idempotency/memory_store.rb +64 -0
- data/lib/openui/rails/actions/invocation.rb +16 -0
- data/lib/openui/rails/actions/registry.rb +113 -0
- data/lib/openui/rails/actions/schema.rb +115 -0
- data/lib/openui/rails/actions/submission.rb +102 -0
- data/lib/openui/rails/actions.rb +18 -0
- data/lib/openui/rails/adapters/ruby_llm.rb +113 -0
- data/lib/openui/rails/configuration.rb +282 -0
- data/lib/openui/rails/engine.rb +46 -0
- data/lib/openui/rails/errors.rb +41 -0
- data/lib/openui/rails/json_value.rb +33 -0
- data/lib/openui/rails/library_manifest.rb +234 -0
- data/lib/openui/rails/message.rb +139 -0
- data/lib/openui/rails/prompt_builder.rb +115 -0
- data/lib/openui/rails/runtime_asset.rb +65 -0
- data/lib/openui/rails/runtime_package.rb +74 -0
- data/lib/openui/rails/stream/accumulator.rb +86 -0
- data/lib/openui/rails/stream/bridge.rb +193 -0
- data/lib/openui/rails/stream/broadcaster.rb +106 -0
- data/lib/openui/rails/stream/envelope.rb +159 -0
- data/lib/openui/rails/stream/transports/action_cable.rb +50 -0
- data/lib/openui/rails/stream/transports/memory.rb +33 -0
- data/lib/openui/rails/stream/transports/turbo_streams.rb +61 -0
- data/lib/openui/rails/stream/transports.rb +41 -0
- data/lib/openui/rails/surface.rb +150 -0
- data/lib/openui/rails/version.rb +5 -0
- data/lib/openui/rails/view_helper.rb +56 -0
- data/lib/openui/rails.rb +33 -0
- data/lib/tasks/openui.rake +16 -0
- 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
|