maquina_stream 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/.rdoc_options +30 -0
- data/CHANGELOG.md +38 -0
- data/LICENSE.txt +21 -0
- data/README.md +163 -0
- data/app/assets/stylesheets/maquina_stream/components/attachment.css +35 -0
- data/app/assets/stylesheets/maquina_stream/components/code_block.css +30 -0
- data/app/assets/stylesheets/maquina_stream/components/shimmer.css +31 -0
- data/app/assets/stylesheets/maquina_stream/components/snippet.css +22 -0
- data/app/assets/stylesheets/maquina_stream/components/source_citation.css +14 -0
- data/app/assets/stylesheets/maquina_stream/components/suggestion.css +32 -0
- data/app/assets/stylesheets/maquina_stream/reveal.css +48 -0
- data/app/assets/stylesheets/maquina_stream/themes/dark.css +235 -0
- data/app/assets/stylesheets/maquina_stream/themes/light.css +117 -0
- data/app/controllers/maquina_stream/application_controller.rb +21 -0
- data/app/controllers/maquina_stream/blocks_controller.rb +41 -0
- data/app/controllers/maquina_stream/manifests_controller.rb +15 -0
- data/app/helpers/maquina_stream/components_helper.rb +80 -0
- data/app/javascript/maquina_stream/controllers/application_controller.js +169 -0
- data/app/javascript/maquina_stream/controllers/ms_autoscroll_controller.js +110 -0
- data/app/javascript/maquina_stream/controllers/ms_code_controller.js +96 -0
- data/app/javascript/maquina_stream/controllers/ms_deferred_controller.js +223 -0
- data/app/javascript/maquina_stream/controllers/ms_diagram_controller.js +40 -0
- data/app/javascript/maquina_stream/controllers/ms_link_safety_controller.js +196 -0
- data/app/javascript/maquina_stream/controllers/ms_math_controller.js +32 -0
- data/app/javascript/maquina_stream/controllers/ms_repair_controller.js +167 -0
- data/app/javascript/maquina_stream/controllers/ms_reveal_controller.js +320 -0
- data/app/javascript/maquina_stream/controllers/ms_table_controller.js +183 -0
- data/app/javascript/maquina_stream/index.js +59 -0
- data/app/views/maquina_stream/components/_attachment.html.erb +139 -0
- data/app/views/maquina_stream/components/_code_block.html.erb +74 -0
- data/app/views/maquina_stream/components/_shimmer.html.erb +36 -0
- data/app/views/maquina_stream/components/_snippet.html.erb +50 -0
- data/app/views/maquina_stream/components/_source_citation.html.erb +42 -0
- data/app/views/maquina_stream/components/_suggestion.html.erb +73 -0
- data/config/importmap.rb +10 -0
- data/config/locales/en.yml +79 -0
- data/config/locales/es.yml +82 -0
- data/config/routes.rb +11 -0
- data/docs/configuration.md +219 -0
- data/docs/deferred-renderers.md +184 -0
- data/docs/getting-started.md +356 -0
- data/docs/javascript.md +298 -0
- data/docs/registries.md +283 -0
- data/docs/repair.md +162 -0
- data/docs/security.md +247 -0
- data/docs/streaming.md +308 -0
- data/lib/generators/maquina_stream/install/USAGE +26 -0
- data/lib/generators/maquina_stream/install/install_generator.rb +199 -0
- data/lib/generators/maquina_stream/install/templates/initializer.rb.tt +121 -0
- data/lib/generators/maquina_stream/streamable/USAGE +28 -0
- data/lib/generators/maquina_stream/streamable/streamable_generator.rb +187 -0
- data/lib/generators/maquina_stream/streamable/templates/migration.rb.tt +21 -0
- data/lib/generators/maquina_stream/streamable/templates/model.rb.tt +4 -0
- data/lib/maquina_stream/block.rb +99 -0
- data/lib/maquina_stream/broadcaster.rb +233 -0
- data/lib/maquina_stream/component_cache.rb +0 -0
- data/lib/maquina_stream/components/contract.rb +184 -0
- data/lib/maquina_stream/components.rb +135 -0
- data/lib/maquina_stream/configuration.rb +240 -0
- data/lib/maquina_stream/document.rb +296 -0
- data/lib/maquina_stream/engine.rb +46 -0
- data/lib/maquina_stream/errors.rb +17 -0
- data/lib/maquina_stream/export.rb +66 -0
- data/lib/maquina_stream/frame.rb +73 -0
- data/lib/maquina_stream/manifest.rb +137 -0
- data/lib/maquina_stream/registries.rb +116 -0
- data/lib/maquina_stream/renderer/fence.rb +115 -0
- data/lib/maquina_stream/renderer/post_pass.rb +363 -0
- data/lib/maquina_stream/renderer/tag_blocks.rb +276 -0
- data/lib/maquina_stream/renderer/view_context.rb +72 -0
- data/lib/maquina_stream/renderer.rb +128 -0
- data/lib/maquina_stream/sanitizer.rb +392 -0
- data/lib/maquina_stream/streamable.rb +281 -0
- data/lib/maquina_stream/text_direction.rb +56 -0
- data/lib/maquina_stream/themes.rb +84 -0
- data/lib/maquina_stream/version.rb +5 -0
- data/lib/maquina_stream.rb +175 -0
- metadata +204 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/named_base"
|
|
4
|
+
require "rails/generators/migration"
|
|
5
|
+
require "maquina_stream/streamable"
|
|
6
|
+
|
|
7
|
+
module MaquinaStream
|
|
8
|
+
module Generators
|
|
9
|
+
# Makes one model streamable: the migration carrying the columns the
|
|
10
|
+
# contract requires, and the `include` plus macro in the model.
|
|
11
|
+
#
|
|
12
|
+
# ```sh
|
|
13
|
+
# bin/rails generate maquina_stream:streamable Message
|
|
14
|
+
# ```
|
|
15
|
+
#
|
|
16
|
+
# Per model, because a host may have several — an assistant message and a
|
|
17
|
+
# tool call are two streams, not one.
|
|
18
|
+
#
|
|
19
|
+
# The columns come from MaquinaStream::Streamable itself rather than from a
|
|
20
|
+
# list copied out of the documentation, so a contract that gains a column
|
|
21
|
+
# gains it here too. A column the contract requires and this generator has
|
|
22
|
+
# no definition for raises rather than being quietly left out.
|
|
23
|
+
class StreamableGenerator < Rails::Generators::NamedBase
|
|
24
|
+
include Rails::Generators::Migration
|
|
25
|
+
|
|
26
|
+
source_root File.expand_path("templates", __dir__)
|
|
27
|
+
|
|
28
|
+
desc "Adds the maquina_stream contract columns and the model macro to one model."
|
|
29
|
+
|
|
30
|
+
class_option :buffer, type: :string, default: "content",
|
|
31
|
+
desc: "The column holding the raw markdown"
|
|
32
|
+
class_option :stream_for, type: :string, default: "record",
|
|
33
|
+
desc: "Ruby for the Turbo broadcast target, written against `record`"
|
|
34
|
+
|
|
35
|
+
INITIALIZER = "config/initializers/maquina_stream.rb"
|
|
36
|
+
|
|
37
|
+
# The stub the install generator writes, and what replaces it.
|
|
38
|
+
FIND_STREAM_STUB = /^(\s*)c\.find_stream = ->\(sid\) \{ raise NotImplementedError.*\}$/
|
|
39
|
+
|
|
40
|
+
# How to create each column the contract asks for. Keyed by the column
|
|
41
|
+
# name Streamable declares, plus `:buffer` for the one the host names.
|
|
42
|
+
# Streamable::Generated::REQUIRED_COLUMNS is the source of truth for
|
|
43
|
+
# *which* columns; this is the source of truth for their shape.
|
|
44
|
+
COLUMN_DEFINITIONS = {
|
|
45
|
+
:buffer => {type: :text, default: "", null: false},
|
|
46
|
+
Streamable::SEQUENCE_COLUMN => {type: :integer, default: 0, null: false},
|
|
47
|
+
Streamable::STATUS_COLUMN => {type: :string, default: Streamable::OPEN_STATUS, null: false}
|
|
48
|
+
}.freeze
|
|
49
|
+
|
|
50
|
+
def self.next_migration_number(dirname)
|
|
51
|
+
ActiveRecord::Migration.next_migration_number(current_migration_number(dirname) + 1)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def create_migration_file
|
|
55
|
+
return say_status(:skip, "db/migrate: #{table_name} already has every contract column", :yellow) if missing_columns.empty?
|
|
56
|
+
return say_status(:skip, "db/migrate: #{migration_name} already generated", :yellow) if migration_generated?
|
|
57
|
+
|
|
58
|
+
migration_template "migration.rb.tt", "db/migrate/#{migration_name}.rb"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def create_or_update_model
|
|
62
|
+
if File.exist?(File.join(destination_root, model_path))
|
|
63
|
+
return say_status(:skip, "#{model_path}: already streamable", :yellow) if model_source.include?("MaquinaStream::Streamable")
|
|
64
|
+
|
|
65
|
+
inject_into_class model_path, class_name, model_macro
|
|
66
|
+
else
|
|
67
|
+
template "model.rb.tt", model_path
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The install generator leaves `find_stream` raising. Now there is a
|
|
72
|
+
# model to point it at — but only the generated stub is ever replaced, so
|
|
73
|
+
# a host that already wrote its own is never clobbered.
|
|
74
|
+
def fill_in_find_stream
|
|
75
|
+
return say_status(:skip, "#{INITIALIZER}: not found — set c.find_stream yourself", :yellow) unless File.exist?(File.join(destination_root, INITIALIZER))
|
|
76
|
+
|
|
77
|
+
source = File.read(File.join(destination_root, INITIALIZER))
|
|
78
|
+
unless source.match?(FIND_STREAM_STUB)
|
|
79
|
+
return say_status(:skip, "#{INITIALIZER}: find_stream is not the generated stub", :yellow)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
gsub_file INITIALIZER, FIND_STREAM_STUB, "\\1c.find_stream = ->(sid) { #{class_name}.find_by(id: sid) }"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def report_what_is_left
|
|
86
|
+
say ""
|
|
87
|
+
say "#{class_name} streams. What is still yours:", :green
|
|
88
|
+
say ""
|
|
89
|
+
say " - `stream_for:` in #{model_path} — the Turbo broadcast target."
|
|
90
|
+
say " Who may subscribe to a stream is your question, not the engine's."
|
|
91
|
+
say " - `c.authorize` in #{INITIALIZER}, which still denies everything."
|
|
92
|
+
say ""
|
|
93
|
+
say " Assert the contract in your own suite, so a missing column fails at"
|
|
94
|
+
say " test time rather than mid-stream:"
|
|
95
|
+
say ""
|
|
96
|
+
say " assert_empty #{class_name}.maquina_stream_contract_gaps"
|
|
97
|
+
say ""
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
# Every column the contract needs, resolved against the buffer name the
|
|
102
|
+
# host chose. Derived from Streamable, never from a copied list.
|
|
103
|
+
def contract_columns
|
|
104
|
+
Streamable::Generated::REQUIRED_COLUMNS.values.uniq.map do |column|
|
|
105
|
+
definition = COLUMN_DEFINITIONS.fetch(column) do
|
|
106
|
+
raise Rails::Generators::Error, <<~MESSAGE
|
|
107
|
+
MaquinaStream::Streamable requires a `#{column}` column and this
|
|
108
|
+
generator has no definition for it. The contract moved; teach
|
|
109
|
+
COLUMN_DEFINITIONS the new column rather than leaving hosts to
|
|
110
|
+
find out mid-stream.
|
|
111
|
+
MESSAGE
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
definition.merge(name: (column == :buffer) ? buffer_column : column)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def missing_columns
|
|
119
|
+
contract_columns.reject { |column| existing_columns.include?(column[:name].to_s) }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def buffer_column
|
|
123
|
+
options[:buffer].to_sym
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def create_table?
|
|
127
|
+
table_definition.nil?
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def migration_name
|
|
131
|
+
create_table? ? "create_#{table_name}" : "add_maquina_stream_to_#{table_name}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def migration_generated?
|
|
135
|
+
Dir.glob(File.join(destination_root, "db/migrate/*_#{migration_name}.rb")).any?
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def migration_version
|
|
139
|
+
ActiveRecord::Migration.current_version.to_s
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# What db/schema.rb says this table already has. A host with no schema
|
|
143
|
+
# yet gets a create_table; one whose table exists gets add_column for
|
|
144
|
+
# the columns it is actually missing.
|
|
145
|
+
def existing_columns
|
|
146
|
+
@existing_columns ||= table_definition.to_s.scan(/t\.\w+\s+[:"']([a-z0-9_]+)/).flatten
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def table_definition
|
|
150
|
+
return @table_definition if defined?(@table_definition)
|
|
151
|
+
|
|
152
|
+
@table_definition = schema[/create_table [:"']#{Regexp.escape(table_name)}["']?[^\n]*\n(.*?)\n\s*end/m, 1]
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def schema
|
|
156
|
+
@schema ||= begin
|
|
157
|
+
path = File.join(destination_root, "db/schema.rb")
|
|
158
|
+
File.exist?(path) ? File.read(path) : ""
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def model_path
|
|
163
|
+
File.join("app/models", class_path, "#{file_name}.rb")
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def model_source
|
|
167
|
+
File.read(File.join(destination_root, model_path))
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def model_macro
|
|
171
|
+
<<~RUBY.indent(2)
|
|
172
|
+
include MaquinaStream::Streamable
|
|
173
|
+
|
|
174
|
+
# `buffer:` names the column holding the raw markdown. `stream_for:`
|
|
175
|
+
# returns the Turbo broadcast target — the engine never guesses one,
|
|
176
|
+
# because who may subscribe to a stream is the host's question. This
|
|
177
|
+
# gives every #{singular_name} a stream of its own; a conversation-wide
|
|
178
|
+
# target is usually what you want:
|
|
179
|
+
#
|
|
180
|
+
# stream_for: ->(record) { [:conversation, record.conversation_id, :#{table_name}] }
|
|
181
|
+
maquina_stream buffer: :#{buffer_column},
|
|
182
|
+
stream_for: ->(record) { #{options[:stream_for]} }
|
|
183
|
+
RUBY
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The columns MaquinaStream::Streamable generates its contract methods from.
|
|
4
|
+
# `<%= buffer_column %>` holds the raw markdown the model writes; the other two are the
|
|
5
|
+
# contract's own, and their names come from Streamable rather than from here.
|
|
6
|
+
class <%= migration_class_name %> < ActiveRecord::Migration[<%= migration_version %>]
|
|
7
|
+
def change
|
|
8
|
+
<% if create_table? -%>
|
|
9
|
+
create_table :<%= table_name %> do |t|
|
|
10
|
+
<% missing_columns.each do |column| -%>
|
|
11
|
+
t.<%= column[:type] %> :<%= column[:name] %>, null: <%= column[:null] %>, default: <%= column[:default].inspect %>
|
|
12
|
+
<% end -%>
|
|
13
|
+
t.timestamps
|
|
14
|
+
end
|
|
15
|
+
<% else -%>
|
|
16
|
+
<% missing_columns.each do |column| -%>
|
|
17
|
+
add_column :<%= table_name %>, :<%= column[:name] %>, :<%= column[:type] %>, null: <%= column[:null] %>, default: <%= column[:default].inspect %>
|
|
18
|
+
<% end -%>
|
|
19
|
+
<% end -%>
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module MaquinaStream
|
|
6
|
+
# One top-level block of a message: the slice of raw markdown it came from,
|
|
7
|
+
# the HTML it rendered to, and whether it is safe to freeze.
|
|
8
|
+
#
|
|
9
|
+
# The id is derived from the index and never from the content. Idiomorph keys
|
|
10
|
+
# on id, so a content-derived id turns every edit into a delete-and-recreate,
|
|
11
|
+
# which loses scroll position, animation state and anything the client owns.
|
|
12
|
+
#
|
|
13
|
+
# Blocks come from Document. Building one by hand is possible but rarely
|
|
14
|
+
# useful: the id and digest a repair needs are stamped on during that split.
|
|
15
|
+
# Instances are immutable — #seal returns a new Block rather than mutating.
|
|
16
|
+
class Block
|
|
17
|
+
# This block's position in the document, zero-based. The id is derived from
|
|
18
|
+
# it.
|
|
19
|
+
attr_reader :index
|
|
20
|
+
|
|
21
|
+
# The slice of raw markdown this block was rendered from.
|
|
22
|
+
attr_reader :markdown
|
|
23
|
+
|
|
24
|
+
# The rendered HTML for this block alone, including its own element and the
|
|
25
|
+
# `id`, `data-ms-block` and `data-ms-block-digest` attributes.
|
|
26
|
+
attr_reader :html
|
|
27
|
+
|
|
28
|
+
# The Range of 1-based source lines this block covers. Coverage has gaps —
|
|
29
|
+
# an HTML block reports no source position at all — so a block with no
|
|
30
|
+
# range of its own inherits the lines between its neighbours.
|
|
31
|
+
attr_reader :line_range
|
|
32
|
+
|
|
33
|
+
# The stream id this block belongs to, or nil for an anonymous render.
|
|
34
|
+
attr_reader :sid
|
|
35
|
+
|
|
36
|
+
# Builds a block. Document does this; a host normally reads blocks rather
|
|
37
|
+
# than constructing them.
|
|
38
|
+
def initialize(index:, markdown:, html:, line_range:, sid: nil, sealed: false, digest: nil)
|
|
39
|
+
@index = index
|
|
40
|
+
@markdown = markdown
|
|
41
|
+
@html = html
|
|
42
|
+
@line_range = line_range
|
|
43
|
+
@sid = sid
|
|
44
|
+
@sealed = sealed
|
|
45
|
+
@digest = digest
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The DOM id, `ms-<sid>-b<index>`. Index-derived, never content-derived:
|
|
49
|
+
# idiomorph keys on it.
|
|
50
|
+
def id
|
|
51
|
+
sid ? "ms-#{sid}-b#{index}" : "ms-b#{index}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Whether this block is frozen — far enough behind the tail that nothing
|
|
55
|
+
# can reinterpret it, and therefore never re-broadcast.
|
|
56
|
+
def sealed? = @sealed
|
|
57
|
+
|
|
58
|
+
# Whether this block can still change, and can therefore still be patched.
|
|
59
|
+
def open? = !sealed?
|
|
60
|
+
|
|
61
|
+
# Digest of the block's rendered CONTENT, not of the source and not of the
|
|
62
|
+
# whole element.
|
|
63
|
+
#
|
|
64
|
+
# The manifest compares what the browser actually has, so two different
|
|
65
|
+
# sources that render alike need no repair between them. Element-level
|
|
66
|
+
# attributes are excluded on purpose: a block gains `data-ms-block-state`
|
|
67
|
+
# when it seals and loses `data-ms-caret` when the tail moves past it, and
|
|
68
|
+
# neither changes what the block says. Digesting them would make every block
|
|
69
|
+
# in every message fetch itself once, for nothing.
|
|
70
|
+
def digest
|
|
71
|
+
@digest ||= Digest::SHA256.hexdigest(html.to_s)[0, 16]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# A copy of this block, sealed. Returns a new Block; the receiver is
|
|
75
|
+
# unchanged.
|
|
76
|
+
def seal
|
|
77
|
+
self.class.new(
|
|
78
|
+
index: index, markdown: markdown, html: html,
|
|
79
|
+
line_range: line_range, sid: sid, sealed: true, digest: digest
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# `[id, digest]` — one row of a Manifest, and the unit the client diffs
|
|
84
|
+
# its own DOM against.
|
|
85
|
+
def to_manifest_entry = [id, digest]
|
|
86
|
+
|
|
87
|
+
# Two blocks are equal when they hold the same index and the same HTML.
|
|
88
|
+
# Sealing is deliberately not part of it: a block that sealed between two
|
|
89
|
+
# frames is the same block, and the seal is a decision about it rather than
|
|
90
|
+
# a property of it.
|
|
91
|
+
def ==(other)
|
|
92
|
+
other.is_a?(Block) && other.index == index && other.html == html
|
|
93
|
+
end
|
|
94
|
+
alias_method :eql?, :==
|
|
95
|
+
|
|
96
|
+
# Hashed on the same two fields #== compares.
|
|
97
|
+
def hash = [index, html].hash
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module MaquinaStream
|
|
6
|
+
# Turns a growing buffer into a stream of small patches.
|
|
7
|
+
#
|
|
8
|
+
# ```ruby
|
|
9
|
+
# broadcaster = MaquinaStream::Broadcaster.new(message)
|
|
10
|
+
# model.stream { |token| broadcaster.append(token) }
|
|
11
|
+
# broadcaster.seal! # final frame, always
|
|
12
|
+
# ```
|
|
13
|
+
#
|
|
14
|
+
# It remembers what the browser already has — block id to digest — and sends
|
|
15
|
+
# only what moved. A block that has not changed is never re-sent, which is the
|
|
16
|
+
# difference between bandwidth tracking drift and bandwidth tracking message
|
|
17
|
+
# length.
|
|
18
|
+
#
|
|
19
|
+
# Transport is a seam. It defaults to Turbo Streams over whatever cable the
|
|
20
|
+
# host configured, and a test can hand it a recorder instead; that recorder is
|
|
21
|
+
# how the bandwidth budget is asserted rather than estimated.
|
|
22
|
+
#
|
|
23
|
+
# One broadcaster per stream, held for the life of that stream: what the
|
|
24
|
+
# browser already has lives in the instance, so a fresh broadcaster mid-stream
|
|
25
|
+
# re-sends every block. It is not thread-safe; drive one stream from one
|
|
26
|
+
# place.
|
|
27
|
+
#
|
|
28
|
+
# **Deltas do not converge on their own, and are not meant to.** Only the open
|
|
29
|
+
# tail is patched; a block that changes after it stops being the tail is fixed
|
|
30
|
+
# by the repair path. Correctness lives there.
|
|
31
|
+
class Broadcaster
|
|
32
|
+
# The host record being streamed. Must satisfy MaquinaStream::Streamable.
|
|
33
|
+
attr_reader :record
|
|
34
|
+
|
|
35
|
+
# The Configuration this broadcaster reads — `frame_budget_ms` and
|
|
36
|
+
# `seal_lag` in particular.
|
|
37
|
+
attr_reader :config
|
|
38
|
+
|
|
39
|
+
# The object frames are emitted through. Responds to
|
|
40
|
+
# `call(record:, frame:, config:)`.
|
|
41
|
+
attr_reader :transport
|
|
42
|
+
|
|
43
|
+
# Builds a broadcaster over one record.
|
|
44
|
+
#
|
|
45
|
+
# `transport:` defaults to TurboTransport. It is a seam: anything answering
|
|
46
|
+
# `call(record:, frame:, config:)` will do, which is how the bandwidth
|
|
47
|
+
# budget is asserted against a recorder rather than estimated.
|
|
48
|
+
def initialize(record, config: MaquinaStream.config, transport: nil)
|
|
49
|
+
@record = record
|
|
50
|
+
@config = config
|
|
51
|
+
@transport = transport || TurboTransport.new
|
|
52
|
+
@known = {}
|
|
53
|
+
@tail_id = nil
|
|
54
|
+
@last_flush = nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Appends `text` to the record's buffer and broadcasts if the frame budget
|
|
58
|
+
# has elapsed. Returns the Frame that went out, or nil when this append was
|
|
59
|
+
# coalesced into the next one.
|
|
60
|
+
#
|
|
61
|
+
# This is the method a streaming loop calls, once per token or per chunk.
|
|
62
|
+
#
|
|
63
|
+
# Host owns persistence: it appends to its own column, and only then is
|
|
64
|
+
# there anything to broadcast.
|
|
65
|
+
def append(text, now: monotonic_ms)
|
|
66
|
+
record.maquina_stream_append(text)
|
|
67
|
+
broadcast(now: now)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Seals the record and emits the final frame. Returns that Frame.
|
|
71
|
+
#
|
|
72
|
+
# `status:` is one of Streamable::SEAL_STATUSES. Call this exactly once,
|
|
73
|
+
# including when a stream failed: an errored message still has text worth
|
|
74
|
+
# keeping, and the client has no other way to learn the stream is over.
|
|
75
|
+
#
|
|
76
|
+
# The final frame is never coalesced and never skipped. Every intra-stream
|
|
77
|
+
# drift becomes cosmetic and self-correcting because of this one.
|
|
78
|
+
def seal!(status: :complete)
|
|
79
|
+
record.maquina_stream_seal!(status: status)
|
|
80
|
+
emit(now: monotonic_ms, final: true)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Broadcasts a frame if the budget has elapsed, without appending
|
|
84
|
+
# anything. Returns the Frame or nil.
|
|
85
|
+
#
|
|
86
|
+
# Useful when the buffer moved by some other route — a host that writes to
|
|
87
|
+
# the column itself and wants the browser told about it.
|
|
88
|
+
#
|
|
89
|
+
# Coalescing happens BEFORE the render, not after it. Building a frame means
|
|
90
|
+
# rendering the whole buffer, so doing that per token and then throwing the
|
|
91
|
+
# result away is how a stream becomes quadratic in message length.
|
|
92
|
+
#
|
|
93
|
+
# Skipping a frame costs nothing: the next one is computed against what the
|
|
94
|
+
# browser actually has, so it carries the accumulated difference.
|
|
95
|
+
def broadcast(now: monotonic_ms)
|
|
96
|
+
return nil unless due?(now)
|
|
97
|
+
|
|
98
|
+
emit(now: now)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# A Document over the record's current buffer, rendered in the mode its
|
|
102
|
+
# status implies. Rebuilt on every call, because the buffer moves.
|
|
103
|
+
#
|
|
104
|
+
# A sealed message renders in static mode, which is what takes the caret
|
|
105
|
+
# off the last block.
|
|
106
|
+
def document
|
|
107
|
+
Document.new(
|
|
108
|
+
record.maquina_stream_buffer,
|
|
109
|
+
config: config,
|
|
110
|
+
sid: record.maquina_stream_id,
|
|
111
|
+
mode: record.maquina_stream_open? ? :streaming : :static
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
# Coalescing: frames inside the budget accumulate instead of going out one
|
|
117
|
+
# per token. The budget is the host's to tune; 60ms is roughly a frame.
|
|
118
|
+
def due?(now)
|
|
119
|
+
return true if @last_flush.nil?
|
|
120
|
+
|
|
121
|
+
now - @last_flush >= config.frame_budget_ms
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def emit(now:, final: false)
|
|
125
|
+
frame = build_frame
|
|
126
|
+
return nil if frame.empty? && !final
|
|
127
|
+
|
|
128
|
+
@last_flush = now
|
|
129
|
+
frame.blocks.each { |block| @known[block.id] = sent_digest(block) }
|
|
130
|
+
|
|
131
|
+
# The sequence belongs to the host's row and moves once per frame that
|
|
132
|
+
# actually goes out, not once per append.
|
|
133
|
+
sequenced = Frame.new(
|
|
134
|
+
seq: record.maquina_stream_advance,
|
|
135
|
+
appends: frame.appends,
|
|
136
|
+
patch: frame.patch,
|
|
137
|
+
final: final
|
|
138
|
+
)
|
|
139
|
+
transport.call(record: record, frame: sequenced, config: config)
|
|
140
|
+
sequenced
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Append what the browser has never seen; patch the open tail, and nothing
|
|
144
|
+
# else.
|
|
145
|
+
#
|
|
146
|
+
# A block often takes its final form in the very frame that opens the one
|
|
147
|
+
# below it — a heading completes as the paragraph after it begins — and so
|
|
148
|
+
# stops being the tail while the client still holds "Status rep".
|
|
149
|
+
# Sending it once more at that handover was measured: it costs a full
|
|
150
|
+
# extra copy of the message, 1.248x -> 2.41x, because it happens once per
|
|
151
|
+
# block. The repair path already fixes it for free, because that change is
|
|
152
|
+
# a CONTENT change and content is exactly what a manifest digest covers.
|
|
153
|
+
#
|
|
154
|
+
# What repair does not fix is presentation chrome (data-ms-reveal, the
|
|
155
|
+
# block state) on a block that sealed after it stopped being the tail: the
|
|
156
|
+
# digest ignores chrome by design. That divergence is left, and corrected
|
|
157
|
+
# by the next reload. See the Phase 7 progress notes.
|
|
158
|
+
def build_frame
|
|
159
|
+
current = document
|
|
160
|
+
tail = current.blocks.last
|
|
161
|
+
|
|
162
|
+
appends = current.blocks.reject { |block| @known.key?(block.id) }
|
|
163
|
+
patch = []
|
|
164
|
+
|
|
165
|
+
if tail && changed?(tail)
|
|
166
|
+
patch << tail
|
|
167
|
+
appends.delete_if { |block| block.id == tail.id }
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
Frame.new(seq: record.maquina_stream_sequence, appends: appends, patch: patch)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# What the client holds is bytes, so "changed" is measured on the bytes.
|
|
174
|
+
# The manifest's digest answers a different question — what the block says
|
|
175
|
+
# — and ignores state and caret attributes on purpose.
|
|
176
|
+
def changed?(block)
|
|
177
|
+
@known.key?(block.id) && @known[block.id] != sent_digest(block)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def sent_digest(block)
|
|
181
|
+
Digest::SHA256.hexdigest(block.html.to_s)[0, 16]
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def monotonic_ms
|
|
185
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1_000
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Default transport. Turbo Streams today; the seam is here so SSE is
|
|
189
|
+
# possible without the broadcaster knowing about it.
|
|
190
|
+
#
|
|
191
|
+
# Appends go out as `broadcast_append_to` against the message element;
|
|
192
|
+
# patches as `broadcast_replace_to` with `method: "morph"`, so idiomorph
|
|
193
|
+
# pairs the node by id instead of recreating it. Every stream action carries
|
|
194
|
+
# `data-ms-seq` and `data-ms-frame`.
|
|
195
|
+
#
|
|
196
|
+
# A no-op when Turbo is not loaded, so the engine's Ruby side stays usable
|
|
197
|
+
# without it.
|
|
198
|
+
class TurboTransport
|
|
199
|
+
# Emits one Frame. The transport contract is this method and nothing
|
|
200
|
+
# else.
|
|
201
|
+
def call(record:, frame:, config:)
|
|
202
|
+
return unless defined?(Turbo::StreamsChannel)
|
|
203
|
+
|
|
204
|
+
target = record.maquina_stream_target
|
|
205
|
+
|
|
206
|
+
frame.appends.each do |block|
|
|
207
|
+
Turbo::StreamsChannel.broadcast_append_to(
|
|
208
|
+
target,
|
|
209
|
+
target: "ms-msg-#{record.maquina_stream_id}",
|
|
210
|
+
content: block.html,
|
|
211
|
+
attributes: frame_attributes(frame, :append)
|
|
212
|
+
)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
frame.patch.each do |block|
|
|
216
|
+
Turbo::StreamsChannel.broadcast_replace_to(
|
|
217
|
+
target,
|
|
218
|
+
target: block.id,
|
|
219
|
+
content: block.html,
|
|
220
|
+
attributes: frame_attributes(frame, :patch).merge("method" => "morph")
|
|
221
|
+
)
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
private
|
|
226
|
+
# The final seal is marked as such, whatever it happens to carry. The
|
|
227
|
+
# repair path triggers on it.
|
|
228
|
+
def frame_attributes(frame, kind)
|
|
229
|
+
{"data-ms-seq" => frame.seq, "data-ms-frame" => frame.final? ? :final : kind}
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
Binary file
|