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,296 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MaquinaStream
4
+ # Splits a rendered message into top-level blocks, and decides which of them
5
+ # are safe to freeze.
6
+ #
7
+ # ```ruby
8
+ # document = MaquinaStream::Document.new(markdown, config: config, sid: "m8f21")
9
+ # document.blocks # every top-level block, in order
10
+ # document.sealed_blocks # frozen, never re-sent
11
+ # document.open_block # the tail, patched on every frame
12
+ # ```
13
+ #
14
+ # The whole buffer is rendered once and then sliced. Blocks are never rendered
15
+ # in isolation: a block that mentions [docs] needs the link reference
16
+ # definition that lives at the bottom of the message, and rendering it alone
17
+ # silently loses it.
18
+ class Document
19
+ # The raw markdown this document was built from.
20
+ attr_reader :markdown
21
+
22
+ # The Configuration it reads — `seal_lag` in particular.
23
+ attr_reader :config
24
+
25
+ # The stream id every block id is prefixed with, or nil for an anonymous
26
+ # render.
27
+ attr_reader :sid
28
+
29
+ # The render mode passed through to Renderer, one of Renderer::MODES.
30
+ attr_reader :mode
31
+
32
+ # Builds a document over one markdown buffer.
33
+ #
34
+ # `sid:` is the record's `maquina_stream_id`. Pass it: block ids are built
35
+ # from it, and a document rendered without one produces ids
36
+ # (`ms-b0`, `ms-b1`) that collide the moment two messages share a page.
37
+ #
38
+ # Nothing is rendered until #blocks or #html is called.
39
+ def initialize(markdown, config: MaquinaStream.config, sid: nil, mode: :streaming)
40
+ @markdown = markdown.to_s
41
+ @config = config
42
+ @sid = sid
43
+ @mode = mode
44
+ end
45
+
46
+ # Every top-level Block, in document order, each already stamped with its
47
+ # id and digest. Memoized: the whole buffer is rendered once, on the first
48
+ # call.
49
+ def blocks
50
+ @blocks ||= build_blocks
51
+ end
52
+
53
+ # Sealed blocks trail the tail by config.seal_lag. Markdown reinterprets
54
+ # retroactively - a paragraph becomes a heading when its underline arrives,
55
+ # a table's delimiter row turns the line above into a header - so a block is
56
+ # only safe to freeze once enough later blocks exist that nothing can reach
57
+ # back into it.
58
+ #
59
+ # The lag is not the whole story. A link reference definition resolves links
60
+ # in blocks arbitrarily far above it, so no fixed lag makes a block with an
61
+ # unresolved reference safe. The pointer stops there instead, and moves on
62
+ # once the definition arrives.
63
+ def sealed_blocks
64
+ blocks.first(seal_pointer)
65
+ end
66
+
67
+ # The blocks behind the seal pointer: still able to change, and therefore
68
+ # still eligible for a patch on the next frame.
69
+ def unsealed_blocks
70
+ blocks.drop(seal_pointer)
71
+ end
72
+
73
+ # How far sealing reached, as an index into #blocks. Sealing is a prefix,
74
+ # so this is both the count of sealed blocks and the position of the first
75
+ # unsealed one.
76
+ def seal_pointer
77
+ @seal_pointer ||= blocks.count(&:sealed?)
78
+ end
79
+
80
+ # The last block — the tail the model is currently writing into.
81
+ def open_block
82
+ blocks.last
83
+ end
84
+
85
+ # The whole document as one rendered HTML string, before it is sliced into
86
+ # blocks. Memoized.
87
+ def html
88
+ @html ||= Renderer.call(markdown, mode: mode, config: config)
89
+ end
90
+
91
+ # `[[id, digest], …]` for the sealed blocks. What Manifest is built from.
92
+ def manifest_entries
93
+ sealed_blocks.map(&:to_manifest_entry)
94
+ end
95
+
96
+ private
97
+ # Blocks are matched to their source range by the index the post-pass
98
+ # stamped on them, never by position in the rendered output. The sanitizer
99
+ # drops nodes - an HTML comment, a disallowed element - and a positional
100
+ # match silently shifts every range after the first drop.
101
+ def build_blocks
102
+ rendered = block_nodes(Nokogiri::HTML5.fragment(html))
103
+ ranges = source_ranges
104
+ indices = block_indices(rendered)
105
+ sealed_count = [rendered.length - config.seal_lag, 0].max
106
+
107
+ rendered.each_with_index.map do |node, position|
108
+ source_index = indices[position]
109
+ range = ranges[source_index]
110
+
111
+ # The digest covers the block's content, before the element-level
112
+ # attributes below are stamped on. Those change when a block seals or
113
+ # when the caret moves past it, and neither changes what it says.
114
+ digest = Digest::SHA256.hexdigest("#{source_index}:#{node.inner_html}")[0, 16]
115
+
116
+ # The DOM contract, from docs/javascript.md. The id is index-derived
117
+ # so idiomorph pairs the node instead of recreating it.
118
+ node["id"] = sid ? "ms-#{sid}-b#{source_index}" : "ms-b#{source_index}"
119
+ node["data-ms-block"] = ""
120
+ node["data-ms-block-index"] = source_index.to_s
121
+ node["data-ms-block-digest"] = digest
122
+
123
+ Block.new(
124
+ index: source_index,
125
+ markdown: slice(range),
126
+ html: node.to_html,
127
+ line_range: range,
128
+ sid: sid,
129
+ sealed: false,
130
+ digest: digest
131
+ )
132
+ end.then { |built| apply_seal(built, sealed_count) }
133
+ end
134
+
135
+ # Inline-level elements. Content inside a block, never a block of their
136
+ # own — and every one of them can be stranded at the top level by the
137
+ # unwrap below.
138
+ INLINE_ELEMENTS = Set[
139
+ "a", "abbr", "b", "br", "cite", "code", "del", "dfn", "em", "i", "img",
140
+ "input", "ins", "kbd", "mark", "q", "s", "samp", "small", "span",
141
+ "strong", "sub", "sup", "time", "u", "var", "wbr"
142
+ ]
143
+
144
+ # Splitting must not be able to lose a character.
145
+ #
146
+ # The sanitizer unwraps an element it does not know — <thinking>,
147
+ # <tool_call>, <citation>, any tag a model invents to carry meaning for the
148
+ # application — and keeps its children. CommonMark has already made that
149
+ # tag an HTML block, so the text under it comes back as a bare text node at
150
+ # the TOP level of the fragment, where `select(&:element?)` used to drop it
151
+ # on the floor. The same happens to an inline element the unwrap strands
152
+ # there.
153
+ #
154
+ # The wrapping belongs here rather than in the sanitizer's final pass. The
155
+ # sanitizer is a pure allowlist over an arbitrary fragment: it runs again
156
+ # on the client over fragments that are deliberately inline, and a pass
157
+ # that invented a <p> around them would change what the caller asked to
158
+ # sanitize. Document is the object that claims every character which
159
+ # survives sanitizing lands in exactly one block, so it is the object that
160
+ # has to make the claim true.
161
+ #
162
+ # Consecutive orphans are wrapped together — text plus the <em> beside it
163
+ # stay one block, as they read — and a run that is only whitespace is
164
+ # inter-block separation, not content, so it is left where it is.
165
+ def block_nodes(fragment)
166
+ fragment.children.to_a
167
+ .slice_when { |before, after| orphan?(before) != orphan?(after) }
168
+ .flat_map { |run| orphan?(run.first) ? [wrap_orphans(run)].compact : run.select(&:element?) }
169
+ end
170
+
171
+ def orphan?(node)
172
+ return true unless node.element?
173
+
174
+ INLINE_ELEMENTS.include?(node.name.downcase)
175
+ end
176
+
177
+ def wrap_orphans(run)
178
+ return nil if run.map(&:text).join.strip.empty?
179
+
180
+ wrapper = Nokogiri::XML::Node.new("p", run.first.document)
181
+ wrapper["data-ms-element"] = "p"
182
+ run.first.add_previous_sibling(wrapper)
183
+ run.each { |node| wrapper.add_child(node) }
184
+ wrapper
185
+ end
186
+
187
+ # The post-pass stamps `data-ms-block-index` on every top-level element it
188
+ # sees; a block wrapped above never passed under it, and neither did an
189
+ # element the unwrap promoted from inside one. Those take the first free
190
+ # index at or after their position, so ids stay unique — the one property
191
+ # idiomorph needs from them. They are not always in ascending order, and
192
+ # they do not have to be: block order is the order of #blocks.
193
+ def block_indices(nodes)
194
+ used = nodes.filter_map { |node| node["data-ms-block-index"]&.to_i }.to_set
195
+ cursor = 0
196
+
197
+ nodes.each_with_index.map do |node, position|
198
+ stamped = node["data-ms-block-index"]&.to_i
199
+ next stamped if stamped
200
+
201
+ cursor = [cursor, position].max
202
+ cursor += 1 while used.include?(cursor)
203
+ used << cursor
204
+ cursor
205
+ end
206
+ end
207
+
208
+ # Sealing is a prefix: the pointer is the position it reaches, so a block
209
+ # that cannot seal holds every block after it open too.
210
+ def apply_seal(built, sealed_count)
211
+ limit = [sealed_count, unresolved_position(built) || sealed_count].min
212
+ built.each_with_index.map { |block, position| (position < limit) ? block.seal : block }
213
+ end
214
+
215
+ def unresolved_position(built)
216
+ built.index { |block| unresolved_references?(block.markdown) }
217
+ end
218
+
219
+ REFERENCE_USE = /\[[^\]\n]*\]\[([^\]\n]*)\]/
220
+ # A definition counts only once its line is complete: "[docs]:" with no
221
+ # destination yet resolves nothing, and "[docs]: https://exa" resolves to
222
+ # a truncated host that changes when the rest arrives. Either would seal a
223
+ # block whose links are still moving.
224
+ REFERENCE_DEFINITION = /^ {0,3}\[([^\]\n]+)\]:[ \t]*\S+[^\n]*\n/
225
+
226
+ def unresolved_references?(source)
227
+ source.scan(REFERENCE_USE).flatten.any? do |label|
228
+ !defined_reference_labels.include?(label.strip.downcase)
229
+ end
230
+ end
231
+
232
+ def defined_reference_labels
233
+ @defined_reference_labels ||= markdown.scan(REFERENCE_DEFINITION).flatten.map { |l| l.strip.downcase }.to_set
234
+ end
235
+
236
+ # Line ranges come from a parse of the raw buffer, not from the rendered
237
+ # output: the post-pass rewrites elements and the sanitizer may drop one,
238
+ # and neither carries a source position afterwards.
239
+ #
240
+ # HTML blocks report no sourcepos at all (confirmed against commonmarker
241
+ # 2.10.0 in test/sourcepos_test.rb), and a link reference definition
242
+ # renders no element whatsoever - so coverage has gaps, and a block with no
243
+ # range of its own inherits the lines between its neighbours.
244
+ def source_ranges
245
+ parsed = Commonmarker.to_html(
246
+ prepared,
247
+ options: Renderer::COMMONMARKER_OPTIONS,
248
+ plugins: Renderer::COMMONMARKER_PLUGINS
249
+ )
250
+
251
+ nodes = Nokogiri::HTML5.fragment(parsed).children.select(&:element?)
252
+ explicit = nodes.map { |node| parse_sourcepos(node["data-sourcepos"]) }
253
+
254
+ fill_gaps(explicit)
255
+ end
256
+
257
+ def parse_sourcepos(value)
258
+ return nil unless value
259
+
260
+ start_line, end_line = value.split("-").map { |part| part.split(":").first.to_i }
261
+ (start_line..end_line)
262
+ end
263
+
264
+ def fill_gaps(ranges)
265
+ ranges.each_with_index.map do |range, index|
266
+ next range if range
267
+
268
+ previous_end = ranges[0...index].compact.last&.last
269
+ next_start = ranges[(index + 1)..].compact.first&.first
270
+
271
+ from = (previous_end || 0) + 1
272
+ to = (next_start || line_count + 1) - 1
273
+ (from > to) ? (from..from) : (from..to)
274
+ end
275
+ end
276
+
277
+ # The same string the renderer parsed: repaired by maquina_remend and
278
+ # normalised by Renderer::TagBlocks. Ranges and slices have to share one
279
+ # coordinate system, and the rendered document is in this one.
280
+ def prepared
281
+ @prepared ||= Renderer.prepare(markdown)
282
+ end
283
+
284
+ def lines
285
+ @lines ||= prepared.lines
286
+ end
287
+
288
+ def line_count = lines.length
289
+
290
+ def slice(range)
291
+ return "" unless range
292
+
293
+ lines[(range.first - 1)..(range.last - 1)].to_a.join
294
+ end
295
+ end
296
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MaquinaStream
4
+ # The Rails engine. Mount it for the repair endpoints:
5
+ #
6
+ # ```ruby
7
+ # mount MaquinaStream::Engine => "/maquina_stream"
8
+ # ```
9
+ #
10
+ # It contributes two routes (see MaquinaStream::ManifestsController and
11
+ # MaquinaStream::BlocksController), the engine's stylesheets and JavaScript,
12
+ # and its importmap pins. It contributes no migrations and no models: the
13
+ # host owns persistence.
14
+ class Engine < ::Rails::Engine
15
+ isolate_namespace MaquinaStream
16
+
17
+ initializer "maquina_stream.streamable" do
18
+ ActiveSupport.on_load(:active_record) do
19
+ # Hosts `include MaquinaStream::Streamable` themselves; requiring it
20
+ # here only guarantees it is loaded before any host model boots.
21
+ require "maquina_stream/streamable"
22
+ end
23
+ end
24
+
25
+ # NoBuild: the engine's JavaScript ships as source and is served by the
26
+ # asset pipeline, pinned into the host's importmap. There is no package.json
27
+ # and no npm dependency anywhere in this gem.
28
+ #
29
+ # Both initializers are no-ops when the host has neither propshaft/sprockets
30
+ # nor importmap-rails: the engine's Ruby side does not require them.
31
+ initializer "maquina_stream.assets" do |app|
32
+ next unless app.config.respond_to?(:assets)
33
+
34
+ app.config.assets.paths << root.join("app/javascript")
35
+ app.config.assets.paths << root.join("app/assets/stylesheets")
36
+ end
37
+
38
+ initializer "maquina_stream.importmap", before: "importmap" do |app|
39
+ next unless app.config.respond_to?(:importmap)
40
+
41
+ app.config.importmap.paths << root.join("config/importmap.rb")
42
+ # Cache-bust the host's importmap when engine JavaScript changes in dev.
43
+ app.config.importmap.cache_sweepers << root.join("app/javascript")
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MaquinaStream
4
+ # Base class for everything this engine raises. Rescue it to catch all of
5
+ # them at once.
6
+ Error = Class.new(StandardError)
7
+
8
+ # Raised when a host model does not satisfy the MaquinaStream::Streamable
9
+ # contract. The message names the method, the column and the class, so the
10
+ # fix is in the error rather than in a document.
11
+ ContractError = Class.new(Error)
12
+
13
+ # Raised when a required host seam has not been configured — `find_stream`,
14
+ # in practice. `authorize` denies instead of raising, because denial is the
15
+ # safe answer.
16
+ ConfigurationError = Class.new(Error)
17
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MaquinaStream
4
+ # A whole message, back out as markdown.
5
+ #
6
+ # ```ruby
7
+ # MaquinaStream::Export.markdown(message) # => String
8
+ # ```
9
+ #
10
+ # The buffer already is markdown, so export is mostly a question of what to do
11
+ # with the parts that are not clean:
12
+ #
13
+ # * **A cancelled stream ends mid-token.** The raw buffer would export a half
14
+ # written bold run or an unterminated fence, so the buffer is repaired first
15
+ # — the same preprocessor the renderer uses, so an export matches what was
16
+ # on screen.
17
+ # * **Deferred content exports as its source**, which is the Phase 6 fallback,
18
+ # decided once and applied everywhere. A diagram exports as its `mermaid`
19
+ # fence, verbatim, because that is what the model wrote and what another
20
+ # tool can read. There is nothing to substitute: rendering it would mean
21
+ # shipping the renderer to the server.
22
+ #
23
+ # A status footer is appended for anything that did not finish, because a
24
+ # cancelled message that exports as though it were complete is a lie in a file
25
+ # somebody keeps.
26
+ module Export
27
+ # The seal statuses that get a footer. `:complete` does not; neither does a
28
+ # stream that is still open, which exports as far as it has got.
29
+ INCOMPLETE = %i[cancelled errored timed_out].freeze
30
+
31
+ class << self
32
+ # A whole message as markdown, as a String.
33
+ #
34
+ # `annotate: false` suppresses the status footer — for a caller that is
35
+ # re-ingesting the text rather than handing a human a file, and that will
36
+ # carry the status some other way.
37
+ #
38
+ # The footer comes from the `maquina_stream.export.<status>` locale key,
39
+ # falling back to `> [status]`.
40
+ def markdown(record, config: MaquinaStream.config, annotate: true)
41
+ buffer = MaquinaRemend.call(record.maquina_stream_buffer.to_s)
42
+ status = status_of(record)
43
+
44
+ return buffer unless annotate && INCOMPLETE.include?(status)
45
+
46
+ "#{buffer.rstrip}\n\n#{note_for(status, config)}\n"
47
+ end
48
+
49
+ private
50
+ def status_of(record)
51
+ return :open if record.maquina_stream_open?
52
+ return nil unless record.respond_to?(:maquina_stream_status)
53
+
54
+ record.maquina_stream_status&.to_sym
55
+ end
56
+
57
+ def note_for(status, config)
58
+ I18n.t(
59
+ "maquina_stream.export.#{status}",
60
+ locale: I18n.locale || config.locale,
61
+ default: "> [#{status}]"
62
+ )
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MaquinaStream
4
+ # One broadcast: what changed since the last one.
5
+ #
6
+ # | Field | What is in it |
7
+ # |---|---|
8
+ # | `appends` | blocks the browser has never seen, sent whole |
9
+ # | `patch` | unsealed blocks whose HTML moved since the last frame |
10
+ #
11
+ # Frames come out of Broadcaster. A host reads them — to assert a bandwidth
12
+ # budget, or to drive a transport of its own — rather than building them.
13
+ #
14
+ # `patch` is a list rather than a single open block. The seal lag keeps `seal_lag` blocks unsealed at all times, and any
15
+ # of them can still change — a paragraph two blocks back becomes a heading
16
+ # when its underline arrives. Sending only the last one would leave the others
17
+ # wrong until a repair.
18
+ class Frame
19
+ # This frame's sequence number, from the host's row. Monotonic, one per
20
+ # frame that actually went out. A client that sees a gap knows it missed
21
+ # something.
22
+ attr_reader :seq
23
+
24
+ # Blocks the browser has never seen, as Block objects. Sent whole.
25
+ attr_reader :appends
26
+
27
+ # Unsealed blocks whose HTML moved since the last frame, as Block objects.
28
+ # Applied by morph, keyed on id.
29
+ attr_reader :patch
30
+
31
+ # Builds a frame. Broadcaster does this.
32
+ def initialize(seq:, appends: [], patch: [], final: false)
33
+ @seq = seq
34
+ @appends = appends
35
+ @patch = patch
36
+ @final = final
37
+ end
38
+
39
+ # The final seal. It is marked on the wire because the repair path triggers
40
+ # on it: docs/design.md calls this the trigger that makes all intra-stream
41
+ # drift cosmetic and self-correcting, and a client cannot know a frame is
42
+ # the last one by looking at it.
43
+ def final? = @final
44
+
45
+ # Whether this frame carries nothing. An empty frame is skipped, unless it
46
+ # is the final one.
47
+ def empty?
48
+ appends.empty? && patch.empty?
49
+ end
50
+
51
+ # Every block in this frame, appends first.
52
+ def blocks
53
+ appends + patch
54
+ end
55
+
56
+ # The HTML this frame puts on the wire, in bytes. What the bandwidth
57
+ # budget is measured in.
58
+ def bytesize
59
+ blocks.sum { |block| block.html.to_s.bytesize }
60
+ end
61
+
62
+ # A summary: sequence, finality, and the block ids on each side. Ids
63
+ # rather than HTML, so it stays readable in a log.
64
+ def to_h
65
+ {
66
+ seq: seq,
67
+ final: final?,
68
+ appends: appends.map(&:id),
69
+ patch: patch.map(&:id)
70
+ }
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module MaquinaStream
6
+ # What the browser is told a message currently *is*, in a bounded number of
7
+ # bytes.
8
+ #
9
+ # ```ruby
10
+ # { seq: 412, cutoff: 38, rollup: "7c1f…",
11
+ # blocks: [["ms-m8f21-b38", "a91c…"], ["ms-m8f21-b39", "4fe2…"]] }
12
+ # ```
13
+ #
14
+ # Not HTML. The client diffs this against its own DOM, asks for the blocks
15
+ # whose digests differ, and morphs only those — so repair costs what has
16
+ # drifted rather than what the message weighs.
17
+ #
18
+ # ## Why it is windowed
19
+ #
20
+ # docs/design.md said the manifest is "a few hundred bytes regardless of
21
+ # message size". Measured, listing every sealed block gave 1.8KB for a 2KB
22
+ # message and 88KB for a 100KB one — it tracked length almost exactly, because
23
+ # block count does. A keyframe every four seconds carrying 88KB is the
24
+ # bandwidth problem the manifest was introduced to prevent.
25
+ #
26
+ # So the manifest carries the last `window` sealed blocks in full, plus one
27
+ # rollup digest covering everything older. A client whose rollup matches knows
28
+ # its history is intact and only has to consider the window; a client whose
29
+ # rollup differs asks for the whole thing, which is rare and is what a cold
30
+ # page load does anyway.
31
+ #
32
+ # Payload is then bounded by the window, not by the message.
33
+ #
34
+ # ## Wire format
35
+ #
36
+ # | Key | Meaning |
37
+ # |---|---|
38
+ # | `seq` | the sequence number this manifest describes |
39
+ # | `cutoff` | how many sealed blocks fall behind the window |
40
+ # | `rollup` | one digest covering every block behind the cutoff |
41
+ # | `blocks` | `[[id, digest], …]` for the blocks inside the window |
42
+ class Manifest
43
+ # The sequence number this manifest describes.
44
+ attr_reader :seq
45
+
46
+ # Every sealed Block, in order — including the ones behind the window,
47
+ # which #rollup covers and #windowed_entries omits.
48
+ attr_reader :blocks
49
+
50
+ # How many sealed blocks are listed in full. Everything older is covered by
51
+ # #rollup. `Float::INFINITY` when the manifest was built with `full: true`.
52
+ attr_reader :window
53
+
54
+ # Builds the manifest for a record, over its current buffer.
55
+ #
56
+ # `full: true` drops the window and lists every sealed block — what a
57
+ # client asks for when its #rollup disagrees with ours and the windowed
58
+ # diff is therefore not enough. Rare, and no more expensive than the cold
59
+ # page load it resembles.
60
+ def self.for(record, config: MaquinaStream.config, window: nil, full: false)
61
+ document = Document.new(
62
+ record.maquina_stream_buffer,
63
+ config: config,
64
+ sid: record.maquina_stream_id,
65
+ mode: record.maquina_stream_open? ? :streaming : :static
66
+ )
67
+
68
+ new(
69
+ seq: record.maquina_stream_sequence,
70
+ blocks: document.sealed_blocks,
71
+ window: full ? Float::INFINITY : (window || config.manifest_window)
72
+ )
73
+ end
74
+
75
+ # Builds a manifest over a list of sealed blocks. `.for` is the usual
76
+ # entry point.
77
+ def initialize(seq:, blocks:, window: 50)
78
+ @seq = seq
79
+ @blocks = blocks
80
+ @window = window
81
+ end
82
+
83
+ # Every sealed block, id and digest. The wire format sends a slice of this.
84
+ def entries
85
+ blocks.map(&:to_manifest_entry)
86
+ end
87
+
88
+ # How many entries fall behind the window and are covered by #rollup
89
+ # instead of being listed. Zero for a full manifest.
90
+ def cutoff
91
+ return 0 if window.infinite?
92
+
93
+ [entries.length - window, 0].max
94
+ end
95
+
96
+ # The entries actually sent: everything from #cutoff onwards.
97
+ def windowed_entries
98
+ entries.drop(cutoff)
99
+ end
100
+
101
+ # One digest covering every block older than the window. Order matters: two
102
+ # clients holding the same blocks in a different order are not in the same
103
+ # state.
104
+ def rollup
105
+ Digest::SHA256.hexdigest(entries.take(cutoff).flatten.join(" "))[0, 16]
106
+ end
107
+
108
+ # The wire format, as a Hash. What the manifest endpoint renders.
109
+ def to_h
110
+ {seq: seq, cutoff: cutoff, rollup: rollup, blocks: windowed_entries}
111
+ end
112
+
113
+ # The wire format, as JSON.
114
+ def to_json(*args)
115
+ to_h.to_json(*args)
116
+ end
117
+
118
+ # Which of the client's blocks disagree with ours, within the window.
119
+ #
120
+ # Blocks the client has and we do not are not reported: the server is the
121
+ # authority on what exists, and a stale block is removed by the morph rather
122
+ # than by a separate instruction.
123
+ def diff(client_digests)
124
+ client = client_digests.to_h
125
+
126
+ windowed_entries.filter_map do |id, digest|
127
+ id unless client[id] == digest
128
+ end
129
+ end
130
+
131
+ # True when the client's view of the history behind the window disagrees
132
+ # with ours, and the windowed diff is therefore not enough.
133
+ def stale_history?(client_rollup)
134
+ cutoff.positive? && client_rollup != rollup
135
+ end
136
+ end
137
+ end