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,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
|