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,392 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+ require "uri"
5
+
6
+ module MaquinaStream
7
+ # Allowlist plus URL hardening. The last pass before output, and it runs
8
+ # unconditionally.
9
+ #
10
+ # ```ruby
11
+ # MaquinaStream::Sanitizer.call(html, config: MaquinaStream.config) # => String
12
+ # ```
13
+ #
14
+ # The input is model output: hostile, prompt-injectable, and never trusted
15
+ # because an earlier stage already looked at it. Nothing here is a cleanup
16
+ # pass — an element or an attribute survives only by being named, and a URL
17
+ # survives only by being re-parsed and re-checked. Everything else is
18
+ # dropped, not escaped and kept.
19
+ #
20
+ # Renderer calls this itself, last, on every render. A host only calls it
21
+ # directly when it produces HTML of its own that a model had a hand in.
22
+ #
23
+ # Five configuration keys steer it — `default_origin`, `allowed_protocols`,
24
+ # `allowed_link_prefixes`, `allowed_image_prefixes` and `allow_data_images`.
25
+ # Nothing else about it is configurable: the element and attribute allowlists
26
+ # are constants, and widening one means editing this file with the regression
27
+ # suite in front of you.
28
+ #
29
+ # It is not the last line of defence either. Renderer output is sanitized
30
+ # *again* client-side before it reaches the DOM, because the payload came from
31
+ # a model and model output is prompt-injectable.
32
+ #
33
+ # See docs/security.md for what survives, what is dropped, and the known
34
+ # holes.
35
+ class Sanitizer
36
+ # Elements that survive: rendered markdown, plus the wrappers the post-pass
37
+ # adds around it.
38
+ ALLOWED_ELEMENTS = %w[
39
+ p div span br hr
40
+ h1 h2 h3 h4 h5 h6
41
+ ul ol li dl dt dd
42
+ table thead tbody tfoot tr td th caption colgroup col
43
+ pre code kbd samp var
44
+ blockquote figure figcaption details summary section article aside
45
+ a img button
46
+ em strong b i u s del ins mark small sub sup q abbr dfn cite time wbr
47
+ input
48
+ ].to_set.freeze
49
+
50
+ # Removed with everything inside them. Unwrapping these would smuggle their
51
+ # contents back into the document: script text, CSS, or a foreign-content
52
+ # (SVG/MathML) subtree whose parsing rules are not HTML's.
53
+ DROP_WITH_CONTENT = %w[
54
+ script style svg math template noscript iframe frame frameset object
55
+ embed applet param form select option optgroup textarea label
56
+ fieldset legend base link meta head title html body audio video source
57
+ track canvas map area portal dialog marquee plaintext xmp listing
58
+ ].to_set.freeze
59
+
60
+ # `hidden` earns its place: the raw-source carrier is a hidden <pre>, and a
61
+ # carrier that loses its hidden attribute renders every code block twice.
62
+ GLOBAL_ATTRIBUTES = %w[id class title lang dir role translate hidden].to_set.freeze
63
+
64
+ ELEMENT_ATTRIBUTES = {
65
+ "a" => %w[href target rel hreflang type],
66
+ # The component controls are buttons, so button cannot be dropped
67
+ # wholesale. It is allowed with a deliberately short attribute list: no
68
+ # `name`, `value`, `form`, `formaction` or `formmethod`, so an injected
69
+ # button cannot submit anything, and `type` is forced to "button" below.
70
+ # Behaviour still comes only from `data-action`, which is already
71
+ # restricted to the ms- namespace, and every ms- controller treats its own
72
+ # values as untrusted.
73
+ "button" => %w[type disabled aria-pressed aria-expanded aria-controls],
74
+ "img" => %w[src alt width height loading decoding],
75
+ "ol" => %w[start reversed type],
76
+ "li" => %w[value],
77
+ "td" => %w[colspan rowspan align valign headers scope],
78
+ "th" => %w[colspan rowspan align valign headers scope abbr],
79
+ "col" => %w[span align],
80
+ "colgroup" => %w[span align],
81
+ "table" => %w[align],
82
+ "input" => %w[type checked disabled],
83
+ "time" => %w[datetime],
84
+ "details" => %w[open]
85
+ }.transform_values { |names| names.to_set.freeze }.freeze
86
+
87
+ # The allowlist already excludes every one of these. Naming them keeps the
88
+ # intent across refactors and gives the regression suite something explicit
89
+ # to assert on.
90
+ FORBIDDEN_ATTRIBUTES = %w[
91
+ srcdoc formaction xlink:href xlink:show xlink:actuate xml:base
92
+ style action background dynsrc lowsrc ping http-equiv srcset usemap
93
+ accesskey contenteditable name
94
+ ].to_set.freeze
95
+
96
+ # Non-Stimulus data hooks our own partials emit.
97
+ STATIC_DATA_ATTRIBUTES = %w[
98
+ data-component data-variant data-size data-slot data-state data-side
99
+ data-orientation data-controller data-action data-turbo-permanent
100
+ data-turbo-temporary
101
+ ].to_set.freeze
102
+
103
+ DATA_ATTRIBUTE_SHAPE = /\Adata-[a-z0-9]+(?:-[a-z0-9]+)*\z/
104
+
105
+ # Component internals: data-code-block-part, data-shimmer-part and friends.
106
+ # They carry no behaviour, only styling hooks for a component's own parts.
107
+ COMPONENT_PART_ATTRIBUTE = /\Adata-[a-z0-9]+(?:-[a-z0-9]+)*-part\z/
108
+ ARIA_ATTRIBUTE_SHAPE = /\Aaria-[a-z]+\z/
109
+
110
+ # Only our own controller namespace. A host or third-party identifier
111
+ # arriving inside model output has no business being instantiated.
112
+ CONTROLLER_IDENTIFIER = /\Ams-[a-z0-9]+(?:-[a-z0-9]+)*\z/
113
+ ACTION_DESCRIPTOR = %r{
114
+ \A
115
+ (?:[a-zA-Z0-9:.-]+(?:@[a-z]+)?->)?
116
+ ms-[a-z0-9-]+\#[a-zA-Z_][a-zA-Z0-9_]*
117
+ (?::[a-z]+)*
118
+ \z
119
+ }x
120
+
121
+ # Rejected however they are spelled, including after the decoding a browser
122
+ # would do on our behalf.
123
+ DANGEROUS_SCHEMES = %w[
124
+ javascript livescript vbscript jscript mocha data file blob about jar
125
+ view-source chrome chrome-extension resource feed ms-its
126
+ ].to_set.freeze
127
+
128
+ # Control characters, and the whitespace a browser strips before it reads
129
+ # the scheme. Removing them first means the scheme we test is the scheme
130
+ # the browser will act on.
131
+ URL_NOISE = /[\u0000-\u0020\u007F-\u00A0\u1680\u180E\u2000-\u200F\u2028\u2029\u202F\u205F\u3000\uFEFF]/
132
+
133
+ DATA_IMAGE = %r{
134
+ \Adata:image/(?:png|jpe?g|gif|webp|avif|bmp|x-icon|vnd\.microsoft\.icon)
135
+ ;base64,[A-Za-z0-9+/=]+\z
136
+ }xi
137
+
138
+ class << self
139
+ # Sanitizes one HTML string. The usual entry point.
140
+ def call(html, config: MaquinaStream.config)
141
+ new(config: config).call(html)
142
+ end
143
+ end
144
+
145
+ # The Configuration whose URL keys this sanitizer reads.
146
+ attr_reader :config
147
+
148
+ # Builds a reusable sanitizer. A nil `config:` falls back to the global
149
+ # one rather than failing later, deep in a URL check.
150
+ def initialize(config: MaquinaStream.config)
151
+ @config = config || MaquinaStream.config
152
+ end
153
+
154
+ # Sanitizes `html` and returns the result as a String — a plain String, not
155
+ # `html_safe`: marking it is the caller's decision, and Renderer is the
156
+ # caller that makes it. Empty in, empty out.
157
+ def call(html)
158
+ return "" if html.nil?
159
+
160
+ source = html.to_s
161
+ return "" if source.empty?
162
+
163
+ fragment = parse(source)
164
+ scrub_children(fragment)
165
+ fragment.to_html
166
+ end
167
+
168
+ private
169
+ # HTML5 parsing wherever Nokogiri offers it. The HTML4 parser's error
170
+ # recovery is not any browser's, and a sanitizer that parses a document
171
+ # differently from the engine that will display it is a mutation XSS
172
+ # waiting for its input.
173
+ def parse(html)
174
+ if defined?(Nokogiri::HTML5)
175
+ Nokogiri::HTML5.fragment(html)
176
+ else
177
+ Nokogiri::HTML::DocumentFragment.parse(html)
178
+ end
179
+ end
180
+
181
+ def scrub_children(node)
182
+ node.children.to_a.each { |child| scrub_node(child) }
183
+ end
184
+
185
+ def scrub_node(node)
186
+ if node.element?
187
+ scrub_element(node)
188
+ elsif node.cdata?
189
+ # A parser artifact. Its characters are inert once they are text and
190
+ # the serializer escapes them.
191
+ node.replace(Nokogiri::XML::Text.new(node.content, node.document))
192
+ elsif !node.text?
193
+ # Comments, processing instructions, doctypes. A conditional comment
194
+ # is script delivery in a costume.
195
+ node.unlink
196
+ end
197
+ end
198
+
199
+ def scrub_element(node)
200
+ name = node.name.downcase
201
+
202
+ return node.unlink if DROP_WITH_CONTENT.include?(name) || foreign?(node)
203
+ return unwrap(node) unless ALLOWED_ELEMENTS.include?(name)
204
+
205
+ scrub_attributes(node, name)
206
+ return unless node.parent # an element rule may have removed the node
207
+
208
+ scrub_children(node)
209
+ end
210
+
211
+ # SVG and MathML subtrees arrive through foreign-content parsing rules,
212
+ # where `<style>` and `<annotation-xml>` re-enter HTML parsing. Nothing in
213
+ # rendered markdown needs either namespace.
214
+ def foreign?(node)
215
+ href = node.namespace&.href.to_s
216
+ href.include?("svg") || href.include?("MathML")
217
+ end
218
+
219
+ def unwrap(node)
220
+ scrub_children(node)
221
+ children = node.children
222
+ children.empty? ? node.unlink : node.replace(children)
223
+ end
224
+
225
+ def scrub_attributes(node, name)
226
+ # A button never submits. Even allowing `type` through, the only value
227
+ # it may hold is "button": an injected `type="submit"` inside a host
228
+ # form would otherwise submit it.
229
+ node["type"] = "button" if name == "button"
230
+
231
+ node.attribute_nodes.each do |attr|
232
+ attr_name = attr.name.downcase
233
+ qualified = attr.namespace ? "#{attr.namespace.prefix}:#{attr_name}" : attr_name
234
+
235
+ # Namespaced attributes (xlink:href, xml:base) never survive: an
236
+ # allowlist for them would have to be a second allowlist.
237
+ next attr.unlink if attr.namespace
238
+ next attr.unlink if FORBIDDEN_ATTRIBUTES.include?(qualified)
239
+ next attr.unlink unless allowed_attribute?(name, attr_name)
240
+
241
+ scrub_attribute_value(node, name, attr, attr_name)
242
+ end
243
+
244
+ enforce_element_rules(node, name)
245
+ end
246
+
247
+ def allowed_attribute?(element, attr_name)
248
+ return false if attr_name.start_with?("on")
249
+ return data_attribute?(attr_name) if attr_name.start_with?("data-")
250
+ return true if attr_name.match?(ARIA_ATTRIBUTE_SHAPE)
251
+ return true if GLOBAL_ATTRIBUTES.include?(attr_name)
252
+
253
+ ELEMENT_ATTRIBUTES.fetch(element, Set[]).include?(attr_name)
254
+ end
255
+
256
+ # `data-ms-*` is ours, and so is every Stimulus value, target, class,
257
+ # param and outlet derived from an `ms-` identifier. Anything else,
258
+ # a third-party controller's values included, is dropped.
259
+ def data_attribute?(attr_name)
260
+ return false unless attr_name.match?(DATA_ATTRIBUTE_SHAPE)
261
+ return true if attr_name.start_with?("data-ms-")
262
+ return true if attr_name.match?(COMPONENT_PART_ATTRIBUTE)
263
+
264
+ STATIC_DATA_ATTRIBUTES.include?(attr_name)
265
+ end
266
+
267
+ def scrub_attribute_value(node, name, attr, attr_name)
268
+ case attr_name
269
+ when "href", "src"
270
+ harden_url(node, name, attr, attr_name)
271
+ when "data-controller"
272
+ filter_tokens(attr) { |token| token.match?(CONTROLLER_IDENTIFIER) }
273
+ when "data-action"
274
+ filter_tokens(attr) { |token| token.match?(ACTION_DESCRIPTOR) }
275
+ when "target"
276
+ attr.value = "_blank" unless %w[_blank _self].include?(attr.value)
277
+ end
278
+ end
279
+
280
+ def filter_tokens(attr)
281
+ kept = attr.value.to_s.split(/\s+/).reject(&:empty?).select { |token| yield token }
282
+ kept.empty? ? attr.unlink : attr.value = kept.join(" ")
283
+ end
284
+
285
+ def harden_url(node, name, attr, attr_name)
286
+ kind = (name == "img" || attr_name == "src") ? :image : :link
287
+ safe = safe_url(attr.value, kind: kind)
288
+
289
+ return attr.value = safe if safe
290
+
291
+ attr.unlink
292
+ # An image with no source is a broken rectangle carrying an
293
+ # attacker-chosen alt string. A link with no href still shows its text.
294
+ node.unlink if name == "img"
295
+ end
296
+
297
+ def safe_url(raw, kind:)
298
+ url = raw.to_s.gsub(URL_NOISE, "")
299
+ return nil if url.empty?
300
+ return url if url.start_with?("#") # same-document fragment
301
+
302
+ # `//evil.example`, and the backslash spellings browsers normalize into
303
+ # it, inherit the page's scheme and none of its origin.
304
+ return nil if url.match?(%r{\A[\\/]{2}}) || url.start_with?("\\")
305
+
306
+ scheme = scheme_of(url)
307
+ decoded_scheme = scheme_of(decode(url))
308
+
309
+ # A scheme that only appears once the browser decodes (`%6Aavascript:`,
310
+ # `java&#115;cript:` double-encoded) is still a scheme.
311
+ return nil if decoded_scheme && decoded_scheme != scheme && DANGEROUS_SCHEMES.include?(decoded_scheme)
312
+
313
+ if scheme
314
+ return (kind == :image) ? data_image_url(url) : nil if scheme == "data"
315
+ return nil unless allowed_protocols.include?(scheme)
316
+ else
317
+ url = resolve(url)
318
+ return nil if url.nil?
319
+ end
320
+
321
+ allowed_prefix?(url, kind: kind) ? url : nil
322
+ end
323
+
324
+ def scheme_of(url)
325
+ match = /\A([a-zA-Z][a-zA-Z0-9+.-]*):/.match(url)
326
+ match && match[1].downcase
327
+ end
328
+
329
+ # What a browser is left with after entity decoding (the HTML parser has
330
+ # usually done one round already; double-encoded payloads survive it) and
331
+ # after percent decoding.
332
+ def decode(url)
333
+ decoded = url.gsub(/&#x([0-9a-fA-F]+);?/) { [Regexp.last_match(1).hex].pack("U") }
334
+ decoded = decoded.gsub(/&#(\d+);?/) { [Regexp.last_match(1).to_i].pack("U") }
335
+ decoded = decoded.gsub(/%([0-9a-fA-F]{2})/) { [Regexp.last_match(1)].pack("H2") }
336
+ decoded.force_encoding(Encoding::UTF_8).gsub(URL_NOISE, "")
337
+ rescue ArgumentError, RangeError
338
+ url
339
+ end
340
+
341
+ # Base64 raster images only. `data:image/svg+xml` is a scriptable
342
+ # document wearing an image's MIME type.
343
+ def data_image_url(url)
344
+ return nil unless allow_data_images?
345
+ return nil unless url.match?(DATA_IMAGE)
346
+
347
+ url
348
+ end
349
+
350
+ def resolve(url)
351
+ origin = config.default_origin
352
+ return url if origin.nil? || origin.to_s.empty?
353
+
354
+ joined = URI.join(origin.to_s, url).to_s
355
+ allowed_protocols.include?(scheme_of(joined).to_s) ? joined : nil
356
+ rescue URI::Error
357
+ nil
358
+ end
359
+
360
+ def allowed_prefix?(url, kind:)
361
+ prefixes = Array((kind == :image) ? config.allowed_image_prefixes : config.allowed_link_prefixes)
362
+ return true if prefixes.empty? || prefixes.include?("*")
363
+
364
+ prefixes.any? { |prefix| url.start_with?(prefix.to_s) }
365
+ end
366
+
367
+ def allowed_protocols
368
+ @allowed_protocols ||= Array(config.allowed_protocols).map { |p| p.to_s.downcase.delete_suffix(":") }
369
+ end
370
+
371
+ def allow_data_images?
372
+ !!config.allow_data_images
373
+ end
374
+
375
+ def enforce_element_rules(node, name)
376
+ case name
377
+ when "input"
378
+ # The tasklist extension's disabled checkbox, and nothing else.
379
+ (node["type"].to_s.downcase == "checkbox") ? node["disabled"] = "disabled" : node.unlink
380
+ when "a"
381
+ harden_link(node)
382
+ end
383
+ end
384
+
385
+ def harden_link(node)
386
+ return unless node["href"]
387
+
388
+ rel = node["rel"].to_s.split(/\s+/) | %w[noopener noreferrer]
389
+ node["rel"] = rel.reject(&:empty?).join(" ")
390
+ end
391
+ end
392
+ end
@@ -0,0 +1,281 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module MaquinaStream
6
+ # Host contract for a streamable record. The host owns persistence; the
7
+ # engine never guesses a broadcast target and never invents authorization.
8
+ #
9
+ # ```ruby
10
+ # class Message < ApplicationRecord
11
+ # include MaquinaStream::Streamable
12
+ #
13
+ # maquina_stream buffer: :content,
14
+ # stream_for: ->(m) { [m.conversation, :messages] }
15
+ # end
16
+ # ```
17
+ #
18
+ # The macro generates the contract methods when the column names match (see
19
+ # docs/streaming.md). Anything the host defines itself wins, and a
20
+ # method whose backing column is missing raises ContractError naming the
21
+ # method, the column and the class.
22
+ #
23
+ # ## What the columns must be
24
+ #
25
+ # | Method | Column it reads |
26
+ # |---|---|
27
+ # | `#maquina_stream_buffer`, `#maquina_stream_append` | whatever `buffer:` named |
28
+ # | `#maquina_stream_sequence`, `#maquina_stream_advance` | `stream_sequence`, an integer |
29
+ # | `#maquina_stream_open?`, `#maquina_stream_seal!`, `#maquina_stream_status` | `stream_status`, a string holding `open` / `complete` / `cancelled` / `errored` / `timed_out` |
30
+ #
31
+ # `#maquina_stream_id` needs no column — it is `to_param`. `#maquina_stream_target`
32
+ # needs none either; it calls the `stream_for:` lambda.
33
+ #
34
+ # ## The generated methods
35
+ #
36
+ # | Method | Returns |
37
+ # |---|---|
38
+ # | `#maquina_stream_id` | `String`, stable and unique per message |
39
+ # | `#maquina_stream_buffer` | `String`, the raw markdown written so far |
40
+ # | `#maquina_stream_append(text)` | the whole buffer after appending and persisting |
41
+ # | `#maquina_stream_sequence` | `Integer`, monotonic, one per frame that went out |
42
+ # | `#maquina_stream_advance` | `Integer`, the next sequence number, incremented atomically |
43
+ # | `#maquina_stream_open?` | `Boolean` |
44
+ # | `#maquina_stream_status` | the recorded end state as a Symbol, or `nil` while open |
45
+ # | `#maquina_stream_seal!(status: :complete)` | the status Symbol it sealed with |
46
+ # | `#maquina_stream_target` | the Turbo broadcast target, from `stream_for:` |
47
+ #
48
+ # Each is documented on Streamable::Generated. Defining any of them in the
49
+ # model body overrides the generated one — the macro `include`s a module, so
50
+ # the class body always wins.
51
+ module Streamable
52
+ extend ActiveSupport::Concern
53
+
54
+ # The column `#maquina_stream_sequence` and `#maquina_stream_advance` read.
55
+ SEQUENCE_COLUMN = :stream_sequence
56
+
57
+ # The column `#maquina_stream_open?`, `#maquina_stream_status` and
58
+ # `#maquina_stream_seal!` read.
59
+ STATUS_COLUMN = :stream_status
60
+
61
+ # The one value of STATUS_COLUMN that means the stream is still being
62
+ # written. Everything else is a seal.
63
+ OPEN_STATUS = "open"
64
+ # A stream that timed out is not the same as one that errored: nothing went
65
+ # wrong, the model simply stopped answering, and the partial text it did
66
+ # produce is still worth keeping and replaying.
67
+ SEAL_STATUSES = %i[complete cancelled errored timed_out].freeze
68
+
69
+ # Every method a host must answer to. The macro generates all of them; a
70
+ # host that cannot use the macro implements this list itself.
71
+ CONTRACT_METHODS = %i[
72
+ maquina_stream_id
73
+ maquina_stream_buffer
74
+ maquina_stream_append
75
+ maquina_stream_sequence
76
+ maquina_stream_advance
77
+ maquina_stream_open?
78
+ maquina_stream_seal!
79
+ maquina_stream_target
80
+ ].freeze
81
+
82
+ included do
83
+ class_attribute :maquina_stream_buffer_column, instance_writer: false
84
+ class_attribute :maquina_stream_target_resolver, instance_writer: false
85
+ end
86
+
87
+ class_methods do
88
+ # Declares this model streamable and generates the contract methods.
89
+ #
90
+ # ```ruby
91
+ # maquina_stream buffer: :content,
92
+ # stream_for: ->(m) { [m.conversation, :messages] }
93
+ # ```
94
+ #
95
+ # `buffer:` names the column holding the raw markdown. `stream_for:` is a
96
+ # callable receiving the record and returning the Turbo broadcast target
97
+ # — the engine never guesses one, because who may listen to a stream is
98
+ # the host's question and not the engine's.
99
+ #
100
+ # The generated methods arrive through an included module, so anything
101
+ # the class body defines wins over them.
102
+ def maquina_stream(buffer:, stream_for:)
103
+ self.maquina_stream_buffer_column = buffer.to_sym
104
+ self.maquina_stream_target_resolver = stream_for
105
+
106
+ include Generated
107
+ end
108
+
109
+ # Names the contract methods this class cannot satisfy — the generated
110
+ # ones whose column is missing and that the host has not defined itself.
111
+ #
112
+ # Returns an Array of method names, empty when the contract is complete.
113
+ # Worth asserting in a host's own test suite: the alternative is finding
114
+ # out mid-stream, when the ContractError raises inside a broadcast.
115
+ def maquina_stream_contract_gaps
116
+ Generated.instance_methods.filter_map do |method|
117
+ column = Generated::REQUIRED_COLUMNS[method]
118
+ next if column.nil?
119
+
120
+ column = maquina_stream_buffer_column if column == :buffer
121
+ method unless maquina_stream_column?(column)
122
+ end
123
+ end
124
+
125
+ # Whether this model has the named column. Answers `false` rather than
126
+ # raising when the database is unreachable — the contract check must not
127
+ # be the thing that breaks a boot.
128
+ def maquina_stream_column?(column)
129
+ column_names.include?(column.to_s)
130
+ rescue ActiveRecord::StatementInvalid, ActiveRecord::NoDatabaseError
131
+ false
132
+ end
133
+ end
134
+
135
+ # The host contract, generated by the `maquina_stream` macro. Included, so
136
+ # a host method defined in the class body overrides it.
137
+ #
138
+ # These are the methods the engine calls on a record — Broadcaster,
139
+ # Manifest, Export and MaquinaStream.render between them use nothing else.
140
+ # A host that cannot use the macro implements this module's public methods
141
+ # itself and never includes it.
142
+ module Generated
143
+ REQUIRED_COLUMNS = {
144
+ maquina_stream_buffer: :buffer,
145
+ maquina_stream_append: :buffer,
146
+ maquina_stream_sequence: SEQUENCE_COLUMN,
147
+ maquina_stream_open?: STATUS_COLUMN,
148
+ maquina_stream_seal!: STATUS_COLUMN
149
+ }.freeze
150
+
151
+ # A stable String, unique per message, used to build every DOM id in the
152
+ # message and to look the record back up in a repair request. Defaults to
153
+ # `to_param`.
154
+ def maquina_stream_id
155
+ to_param.to_s
156
+ end
157
+
158
+ # The raw markdown written so far, as a String. Never HTML: the buffer is
159
+ # what the model wrote, and rendering it is the engine's job.
160
+ #
161
+ # Raises ContractError when the column named by `buffer:` does not exist.
162
+ def maquina_stream_buffer
163
+ maquina_stream_read(buffer_column, :maquina_stream_buffer).to_s
164
+ end
165
+
166
+ # Appends `text` to the buffer, persists it, and returns the whole
167
+ # buffer.
168
+ #
169
+ # The host owns persistence, which is why this and not the broadcaster
170
+ # writes. Broadcaster#append calls it first and only then has something
171
+ # to broadcast.
172
+ #
173
+ # Raises ContractError when the buffer column does not exist.
174
+ def maquina_stream_append(text)
175
+ column = buffer_column
176
+ maquina_stream_require_column!(column, :maquina_stream_append)
177
+ update!(column => "#{public_send(column)}#{text}")
178
+ maquina_stream_buffer
179
+ end
180
+
181
+ # The current frame sequence number, as an Integer. Monotonic. It moves
182
+ # once per frame that actually goes out, not once per append — the client
183
+ # uses it to notice that it missed one.
184
+ def maquina_stream_sequence
185
+ maquina_stream_read(SEQUENCE_COLUMN, :maquina_stream_sequence).to_i
186
+ end
187
+
188
+ # Increments the sequence and returns the new value.
189
+ #
190
+ # Added in Phase 3. The sequence is documented as "incremented per frame",
191
+ # but the Broadcaster cannot write host state directly without
192
+ # contradicting "host owns persistence" - so it asks, through the
193
+ # contract, and the host's database does the incrementing atomically.
194
+ # That is also what keeps it monotonic under concurrent appends.
195
+ def maquina_stream_advance
196
+ maquina_stream_require_column!(SEQUENCE_COLUMN, :maquina_stream_advance)
197
+ self.class.where(id: id).update_all("#{SEQUENCE_COLUMN} = #{SEQUENCE_COLUMN} + 1")
198
+ reload.maquina_stream_sequence
199
+ end
200
+
201
+ # The recorded end state as a Symbol — one of SEAL_STATUSES — for replay
202
+ # and export. Nil while still open.
203
+ #
204
+ # Export reads this to decide whether to append a status footer: a
205
+ # cancelled message that exports as though it were complete is a lie in a
206
+ # file somebody keeps.
207
+ def maquina_stream_status
208
+ return nil if maquina_stream_open?
209
+
210
+ maquina_stream_read(STATUS_COLUMN, :maquina_stream_status)&.to_sym
211
+ end
212
+
213
+ # Whether the stream is still being written.
214
+ #
215
+ # This is what decides render mode and what decides cacheability: an open
216
+ # message is never cached, because it is about to change.
217
+ def maquina_stream_open?
218
+ maquina_stream_read(STATUS_COLUMN, :maquina_stream_open?).to_s == OPEN_STATUS
219
+ end
220
+
221
+ # Closes the stream and returns the status Symbol it sealed with.
222
+ #
223
+ # `status:` must be one of SEAL_STATUSES: `:complete`, `:cancelled`,
224
+ # `:errored` or `:timed_out`. Anything else raises ArgumentError.
225
+ #
226
+ # Sealing only records the status. Broadcaster#seal! is what also emits
227
+ # the final frame, and that frame is never coalesced and never skipped —
228
+ # so a host seals through the broadcaster, not through this directly,
229
+ # unless it means to close a stream silently.
230
+ def maquina_stream_seal!(status: :complete)
231
+ unless SEAL_STATUSES.include?(status.to_sym)
232
+ raise ArgumentError, "status must be one of #{SEAL_STATUSES.join(", ")}, got #{status.inspect}"
233
+ end
234
+
235
+ maquina_stream_require_column!(STATUS_COLUMN, :maquina_stream_seal!)
236
+ update!(STATUS_COLUMN => status.to_s)
237
+ status.to_sym
238
+ end
239
+
240
+ # The Turbo broadcast target, from the `stream_for:` callable the macro
241
+ # was given. Whatever that callable returns is passed straight to
242
+ # `Turbo::StreamsChannel`.
243
+ #
244
+ # Raises ContractError when `stream_for:` is not callable: the broadcast
245
+ # target belongs to the host, and the engine never guesses one.
246
+ def maquina_stream_target
247
+ resolver = self.class.maquina_stream_target_resolver
248
+ unless resolver.respond_to?(:call)
249
+ raise ContractError, <<~MESSAGE
250
+ #{self.class.name} does not supply #maquina_stream_target: `stream_for:`
251
+ is not callable. The broadcast target belongs to the host — the engine
252
+ never guesses one. See docs/streaming.md.
253
+ MESSAGE
254
+ end
255
+
256
+ resolver.call(self)
257
+ end
258
+
259
+ private
260
+ def buffer_column
261
+ self.class.maquina_stream_buffer_column
262
+ end
263
+
264
+ def maquina_stream_read(column, method)
265
+ maquina_stream_require_column!(column, method)
266
+ public_send(column)
267
+ end
268
+
269
+ def maquina_stream_require_column!(column, method)
270
+ return if self.class.maquina_stream_column?(column)
271
+
272
+ raise ContractError, <<~MESSAGE
273
+ #{self.class.name} does not satisfy MaquinaStream::Streamable: ##{method}
274
+ needs a `#{column}` column, and #{self.class.name} has none. Add the column,
275
+ or define ##{method} on #{self.class.name} yourself.
276
+ See docs/streaming.md.
277
+ MESSAGE
278
+ end
279
+ end
280
+ end
281
+ end