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.
Files changed (79) hide show
  1. checksums.yaml +7 -0
  2. data/.rdoc_options +30 -0
  3. data/CHANGELOG.md +38 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +163 -0
  6. data/app/assets/stylesheets/maquina_stream/components/attachment.css +35 -0
  7. data/app/assets/stylesheets/maquina_stream/components/code_block.css +30 -0
  8. data/app/assets/stylesheets/maquina_stream/components/shimmer.css +31 -0
  9. data/app/assets/stylesheets/maquina_stream/components/snippet.css +22 -0
  10. data/app/assets/stylesheets/maquina_stream/components/source_citation.css +14 -0
  11. data/app/assets/stylesheets/maquina_stream/components/suggestion.css +32 -0
  12. data/app/assets/stylesheets/maquina_stream/reveal.css +48 -0
  13. data/app/assets/stylesheets/maquina_stream/themes/dark.css +235 -0
  14. data/app/assets/stylesheets/maquina_stream/themes/light.css +117 -0
  15. data/app/controllers/maquina_stream/application_controller.rb +21 -0
  16. data/app/controllers/maquina_stream/blocks_controller.rb +41 -0
  17. data/app/controllers/maquina_stream/manifests_controller.rb +15 -0
  18. data/app/helpers/maquina_stream/components_helper.rb +80 -0
  19. data/app/javascript/maquina_stream/controllers/application_controller.js +169 -0
  20. data/app/javascript/maquina_stream/controllers/ms_autoscroll_controller.js +110 -0
  21. data/app/javascript/maquina_stream/controllers/ms_code_controller.js +96 -0
  22. data/app/javascript/maquina_stream/controllers/ms_deferred_controller.js +223 -0
  23. data/app/javascript/maquina_stream/controllers/ms_diagram_controller.js +40 -0
  24. data/app/javascript/maquina_stream/controllers/ms_link_safety_controller.js +196 -0
  25. data/app/javascript/maquina_stream/controllers/ms_math_controller.js +32 -0
  26. data/app/javascript/maquina_stream/controllers/ms_repair_controller.js +167 -0
  27. data/app/javascript/maquina_stream/controllers/ms_reveal_controller.js +320 -0
  28. data/app/javascript/maquina_stream/controllers/ms_table_controller.js +183 -0
  29. data/app/javascript/maquina_stream/index.js +59 -0
  30. data/app/views/maquina_stream/components/_attachment.html.erb +139 -0
  31. data/app/views/maquina_stream/components/_code_block.html.erb +74 -0
  32. data/app/views/maquina_stream/components/_shimmer.html.erb +36 -0
  33. data/app/views/maquina_stream/components/_snippet.html.erb +50 -0
  34. data/app/views/maquina_stream/components/_source_citation.html.erb +42 -0
  35. data/app/views/maquina_stream/components/_suggestion.html.erb +73 -0
  36. data/config/importmap.rb +10 -0
  37. data/config/locales/en.yml +79 -0
  38. data/config/locales/es.yml +82 -0
  39. data/config/routes.rb +11 -0
  40. data/docs/configuration.md +219 -0
  41. data/docs/deferred-renderers.md +184 -0
  42. data/docs/getting-started.md +356 -0
  43. data/docs/javascript.md +298 -0
  44. data/docs/registries.md +283 -0
  45. data/docs/repair.md +162 -0
  46. data/docs/security.md +247 -0
  47. data/docs/streaming.md +308 -0
  48. data/lib/generators/maquina_stream/install/USAGE +26 -0
  49. data/lib/generators/maquina_stream/install/install_generator.rb +199 -0
  50. data/lib/generators/maquina_stream/install/templates/initializer.rb.tt +121 -0
  51. data/lib/generators/maquina_stream/streamable/USAGE +28 -0
  52. data/lib/generators/maquina_stream/streamable/streamable_generator.rb +187 -0
  53. data/lib/generators/maquina_stream/streamable/templates/migration.rb.tt +21 -0
  54. data/lib/generators/maquina_stream/streamable/templates/model.rb.tt +4 -0
  55. data/lib/maquina_stream/block.rb +99 -0
  56. data/lib/maquina_stream/broadcaster.rb +233 -0
  57. data/lib/maquina_stream/component_cache.rb +0 -0
  58. data/lib/maquina_stream/components/contract.rb +184 -0
  59. data/lib/maquina_stream/components.rb +135 -0
  60. data/lib/maquina_stream/configuration.rb +240 -0
  61. data/lib/maquina_stream/document.rb +296 -0
  62. data/lib/maquina_stream/engine.rb +46 -0
  63. data/lib/maquina_stream/errors.rb +17 -0
  64. data/lib/maquina_stream/export.rb +66 -0
  65. data/lib/maquina_stream/frame.rb +73 -0
  66. data/lib/maquina_stream/manifest.rb +137 -0
  67. data/lib/maquina_stream/registries.rb +116 -0
  68. data/lib/maquina_stream/renderer/fence.rb +115 -0
  69. data/lib/maquina_stream/renderer/post_pass.rb +363 -0
  70. data/lib/maquina_stream/renderer/tag_blocks.rb +276 -0
  71. data/lib/maquina_stream/renderer/view_context.rb +72 -0
  72. data/lib/maquina_stream/renderer.rb +128 -0
  73. data/lib/maquina_stream/sanitizer.rb +392 -0
  74. data/lib/maquina_stream/streamable.rb +281 -0
  75. data/lib/maquina_stream/text_direction.rb +56 -0
  76. data/lib/maquina_stream/themes.rb +84 -0
  77. data/lib/maquina_stream/version.rb +5 -0
  78. data/lib/maquina_stream.rb +175 -0
  79. 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,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= class_name %> < ApplicationRecord
4
+ <%= model_macro %>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