turbostreamer 1.11.0 → 2.0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 28af7502575ff95a77ab4771bd22fd1010c265f76a246b187333ccbfd8dc0832
4
- data.tar.gz: 3814e0e465cecc5f568fc8c7c91eb949835d03c150d7d1f52c7080f75473828d
3
+ metadata.gz: 0304ed518c7b493ea45ca44b3b3165d88a8f8171b7a3492b86c95a0c7b856617
4
+ data.tar.gz: e8b9e0d3335c16375794e0de491aa34ef176e06f2d49754e918f28ea492fe305
5
5
  SHA512:
6
- metadata.gz: 817908e5623d3d2e0a3ff5cb4593ebf06f454ce1f1b3417b92b6f45d521dd50b8c2fe1a45358eb34e10e5cf8d39c93ab10c3652e7e09ba526bac4bc2ddcecc66
7
- data.tar.gz: c214d24143ffd1e67e237afff5312e5087ea3afba1f3e33b08b71be475757857886b2691c69a2564c8dbe1220ec7597585ab1c893a5926d9af5429a7b4814662
6
+ metadata.gz: a61cc82094a231be929bd06358d64566364ac587741af0de0764b5d1db4279dfdcb14e7b4244fc249f447d70ca5f75a58dcbc8d72c244fbd02edde1b75f000c2
7
+ data.tar.gz: b6e7abe601616b9fdd57e51acb9f4c24d34d7571c85c51e1a655c2d89cdc70849f62ef7bfeb8513078439a84ee6db6e5f84a9434cc75d97a5bb8d2008be1f757
data/CHANGELOG.md ADDED
@@ -0,0 +1,86 @@
1
+ # Changelog
2
+
3
+ 2.0.0
4
+ -----
5
+
6
+ Breaking:
7
+
8
+ * Requires Rails 8.0+ and Ruby 3.3+.
9
+ * `write` is no longer aliased onto `ActionView::OutputBuffer` and
10
+ `ActionView::StreamingBuffer`. Installing the gem used to add a non-escaping
11
+ append to every buffer in the application; the encoders now write into
12
+ `ActionView::TurboBuffer` instead. Code outside TurboStreamer that called
13
+ `output_buffer.write` was relying on that alias and will need `safe_concat`.
14
+ * `ActionView::JSONStreamingBuffer` is now `ActionView::StreamingTurboBuffer`.
15
+
16
+ * Optimize internal `extract!` calls to save on memory allocation [PR #25](https://github.com/malomalo/turbostreamer/pull/25)
17
+ * Add `frozen_string_literal` magic comments
18
+ * Remove some old Rails code
19
+ * Add Rails 8.0 & 8.1 to CI; drop support for Rails < 8.0 and Ruby < 3.3
20
+ * Package `LICENSE` and `CHANGELOG.md` with the gem, and fix `spec.files` dropping
21
+ everything but `README.md` when the gem is built on a shell without brace expansion
22
+ * Layouts now work. A `.json.streamer` layout places the template with
23
+ `json.yield!`. Layout and template share one builder, so the template writes
24
+ into the same stream rather than being buffered and spliced. The layout
25
+ renders first and yields to the template, the reverse of an ERB layout.
26
+ Applies both to `render stream: true` and to ordinary rendering; previously a
27
+ streamed layout was resolved and then discarded, and an unstreamed one had no
28
+ way to place the template's JSON at all.
29
+ * Fix streaming JSON raising `NoMethodError: undefined method 'instrument'`.
30
+ `AbstractRenderer#instrument` was removed in Rails 6.1, so every streamed
31
+ render failed -- silently, since `Body#each` rescues and substitutes an error
32
+ page. It now notifies `render_template.action_view` directly.
33
+ * Stop aliasing `write` onto `ActionView::OutputBuffer` and
34
+ `ActionView::StreamingBuffer`. The encoders now stream into `TurboStreamer::Buffer`,
35
+ which wraps the ActionView buffer instead of patching it, so the non-escaping
36
+ `write` is no longer added to every buffer in the application.
37
+ `ActionView::JSONStreamingBuffer` moves to `TurboStreamer::StreamingBuffer`.
38
+
39
+ 1.11.0 - 2024-04-29
40
+ -----
41
+ * Fix timestamp precision for Rails [PR #24](https://github.com/malomalo/turbostreamer/pull/24)
42
+ * Fix CI yajl archive download URL [PR #22](https://github.com/malomalo/turbostreamer/pull/22)
43
+
44
+ 1.10.0
45
+ -----
46
+ * Fixed Rails 6.1 & Ruby 3.0 Compatibility
47
+
48
+ 1.9.0
49
+ -----
50
+ * Fixed deprecation of using `Proc.new` to capture block; replaced with `&block`
51
+
52
+ 1.8.0
53
+ -----
54
+ * Make the StreamingRenderer Rails 6 compatible [PR #15](https://github.com/malomalo/turbostreamer/issues/15)
55
+ * Update gemspec to require Ruby 2.5+ [PR #14](https://github.com/malomalo/turbostreamer/issues/14)
56
+
57
+ 1.7.0
58
+ -----
59
+ * Add the ability to set default options for encoders
60
+ * Allow setting the `buffer_size` on the OJ encode
61
+ * Reduce find_template calls [PR #11](https://github.com/malomalo/turbostreamer/pull/1)
62
+ * Don't require a layout to stream template in Rails
63
+
64
+ 1.5.0
65
+ -----
66
+ * Add Rails 6.0 support
67
+ * Drop Rails 4.2 support
68
+
69
+ 1.4.0
70
+ -----
71
+ * Replace deprecated fragment_cache_key for Rails 5.2 support
72
+
73
+ 1.3.0
74
+ -----
75
+ * Bump version and update bundler
76
+
77
+ 1.2.0
78
+ -----
79
+ * Add `TurboStreamer#merge!` to merge a hash or array into the current json stream.
80
+
81
+ 1.1.0
82
+ -----
83
+ * Add `Oj` as an encoder option
84
+ * Add ability to pass encoder as an option to `TurboStreamer#new` (symbol or class)
85
+ * Ability to set default encoder for mime type with `TurboStreamer#set_default_encoder`
86
+ * Add some performance test
data/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2011-2017 David Heinemeier Hansson, 37signals
4
+ Copyright (c) 2017 Jonathan Bracy
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
data/README.md CHANGED
@@ -191,6 +191,85 @@ json.partial! partial: 'posts/post', collection: @posts, as: :post
191
191
  json.comments @post.comments, partial: 'comment/comment', as: :comment
192
192
  ```
193
193
 
194
+ ### Layouts
195
+
196
+ A `.json.streamer` layout can wrap the template. Call `json.yield!` where the
197
+ template's JSON should go:
198
+
199
+ ```ruby
200
+ # app/views/layouts/application.json.streamer
201
+ json.object! do
202
+ json.meta do
203
+ json.object! { json.version 1 }
204
+ end
205
+ json.key! :data
206
+ json.yield!
207
+ end
208
+
209
+ # app/views/posts/index.json.streamer
210
+ json.array! @posts, :id, :title
211
+
212
+ # => { "meta": { "version": 1 }, "data": [ { "id": 1, "title": "..." } ] }
213
+ ```
214
+
215
+ The layout and the template share one builder, so the template writes straight
216
+ into the same stream at the point it is yielded — nothing is buffered into a
217
+ string and spliced back in. This works whether or not the response is streamed
218
+ with `render stream: true`; when it is, a large response still arrives in
219
+ chunks.
220
+
221
+ `json.yield!` goes anywhere a value goes, including inside an array:
222
+
223
+ ```ruby
224
+ json.array! do
225
+ json.child! { json.object! { json.first true } }
226
+ json.child! { json.yield! }
227
+ end
228
+ ```
229
+
230
+ A layout that only wraps is just the one call:
231
+
232
+ ```ruby
233
+ json.yield!
234
+ ```
235
+
236
+ A layout may yield more than once, and one that never yields renders without the
237
+ template — both as an ERB layout does. The difference is that ERB replays a
238
+ buffered string, whereas each `json.yield!` renders the template again, so
239
+ anything it does happens again too:
240
+
241
+ ```ruby
242
+ json.array! do
243
+ json.child! { json.yield! }
244
+ json.child! { json.yield! } # the template is rendered a second time
245
+ end
246
+ ```
247
+
248
+ #### Why `json.yield!` and not `yield`
249
+
250
+ `yield` is a Ruby keyword that returns a value, and the template's JSON is never
251
+ a value here — it is written into the stream at the position the layout has
252
+ reached. Placing it therefore has to go through the builder, and `json.yield!`
253
+ matches the rest of the DSL, where the methods that write something end in `!`:
254
+ `object!`, `array!`, `child!`, `partial!`, `merge!`, `cache!`. A bare `yield` in
255
+ a `.json.streamer` layout raises `LocalJumpError` naming `json.yield!`.
256
+
257
+ #### Layouts are not ERB layouts
258
+
259
+ Two differences worth knowing:
260
+
261
+ * **The layout renders first**, and `json.yield!` renders the template it wraps.
262
+ An ERB layout is the other way around: the template is rendered up front and
263
+ the layout concatenates the resulting string. Reversing it is what lets the
264
+ template write into the layout's builder instead of being encoded to a string
265
+ and spliced back in — which neither encoder can do into a keyed slot.
266
+ * **There is one yield, and it has no name.** `content_for` / `provide` have no
267
+ analogue, and a layout cannot ask for content the template defines later. In
268
+ ERB that works because the layout runs in a Fiber and suspends until the
269
+ template provides the key; here the layout calls the template directly, so
270
+ there is nothing to suspend. A JSON document's shape is positional, so one
271
+ yield in one place is generally what you want.
272
+
194
273
  You can explicitly make TurboStreamer object return null if you want:
195
274
 
196
275
  ``` ruby
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Hands each chunk to a block as it is encoded rather than accumulating it,
4
+ # so StreamingTemplateRenderer can push straight to the client instead of
5
+ # buffering the whole response.
6
+ #
7
+ # Like TurboBuffer, it subclasses its ActionView counterpart and takes over
8
+ # the block that one was writing to, so the inherited methods stay consistent
9
+ # with what has been written.
10
+ class ActionView::StreamingTurboBuffer < ActionView::StreamingBuffer
11
+
12
+ def initialize(block)
13
+ @block = block
14
+ end
15
+
16
+ # The encoders -- and the C extensions behind them, Oj::StreamWriter and
17
+ # Wankel::StreamEncoder -- write their output with the IO-style `write`,
18
+ # which ActionView's buffers do not have.
19
+ def write(value)
20
+ string = value.to_s
21
+ @block.call(string)
22
+ string.bytesize
23
+ end
24
+
25
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Prepended to StreamingTemplateRenderer by the railtie.
4
+ #
5
+ # ActionView's own streaming renders the *layout* and buffers the template
6
+ # into a string for it, which would defeat the point here -- the whole JSON
7
+ # document would be built in memory before a byte was written. So streamer
8
+ # templates take their own path, writing to the client as the encoder
9
+ # produces bytes.
10
+ module ActionView::StreamingTurboTemplateRenderer
11
+
12
+ def render_template(view, template, layout_name = nil, locals = {})
13
+ return super unless template.handler == TurboStreamer::Handler
14
+
15
+ locals ||= {}
16
+ layout = layout_name && find_layout(layout_name, locals.keys, [formats.first])
17
+ log_skipped_layout(layout_name) if layout_name && layout.nil?
18
+
19
+ ActionView::StreamingTemplateRenderer::Body.new do |buffer|
20
+ delayed_render_json(buffer, template, layout, view, locals)
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def delayed_render_json(buffer, template, layout, view, locals)
27
+ output = ActionView::StreamingTurboBuffer.new(buffer)
28
+
29
+ ActiveSupport::Notifications.instrument(
30
+ "render_template.action_view",
31
+ identifier: template.identifier,
32
+ layout: layout && layout.virtual_path,
33
+ locals: locals
34
+ ) do
35
+ if layout
36
+ render_json_layout(view, layout, locals, output) do |json|
37
+ template.render(view, locals.merge(json: json), output)
38
+ end
39
+ else
40
+ template.render(view, locals, output)
41
+ end
42
+ end
43
+ end
44
+
45
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The encoders -- and the C extensions behind them, Oj::StreamWriter and
4
+ # Wankel::StreamEncoder -- write their output with the IO-style `write`.
5
+ # ActionView's buffers only expose `<<` / `concat` / `safe_concat`, so they
6
+ # have to be adapted before an encoder can stream into one.
7
+ #
8
+ # Subclassing OutputBuffer means this *is* one: Template#render finishes with
9
+ # `result.is_a?(OutputBuffer) ? result.to_s : result`, so target! can hand it
10
+ # back directly instead of unwrapping. It takes over the String the given
11
+ # buffer was writing to rather than keeping one of its own, so the inherited
12
+ # methods all report on the same content and there is nothing to delegate.
13
+ #
14
+ # See StreamingTurboBuffer for the streaming counterpart.
15
+ class ActionView::TurboBuffer < ActionView::OutputBuffer
16
+
17
+ # instance_of? rather than is_a?, since this class is itself an
18
+ # OutputBuffer and must not be wrapped a second time.
19
+ def self.wrap(buffer)
20
+ if buffer.instance_of?(ActionView::OutputBuffer)
21
+ ActionView::TurboBuffer::new(buffer.raw_buffer)
22
+ elsif buffer.instance_of?(ActionView::StreamingBuffer)
23
+ ActionView::StreamingTurboBuffer.new(buffer.block)
24
+ elsif buffer.respond_to?(:write)
25
+ buffer
26
+ else
27
+ raise ArgumentError, "#{buffer.class} has no #write, so an encoder cannot stream into it"
28
+ end
29
+ end
30
+
31
+ def initialize(raw_buffer)
32
+ @raw_buffer = raw_buffer
33
+ end
34
+
35
+ # Whatever reaches here is already encoded JSON, so it is appended verbatim
36
+ # instead of being HTML-escaped -- the same thing the old alias did by
37
+ # pointing `write` at `safe_concat`.
38
+ def write(value)
39
+ string = value.to_s
40
+ @raw_buffer << string
41
+ string.bytesize
42
+ end
43
+
44
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Prepended to TemplateRenderer by the railtie.
4
+ module ActionView::TurboTemplateRenderer
5
+
6
+ private
7
+
8
+ # ActionView renders the template into a string first and then hands that
9
+ # string to the layout. That works for ERB, where the layout concatenates
10
+ # text.
11
+ #
12
+ # For streamer templates the order is reversed. The layout renders first
13
+ # and its `yield` renders the template into the layout's own
14
+ # builder, which is what the streaming renderer does too.
15
+ def render_template(view, template, layout_name, locals)
16
+ return super unless template.handler == TurboStreamer::Handler
17
+
18
+ # find_layout returns nil when the layout exists in another format but
19
+ # not this one -- an app with layouts/application.html.erb and no JSON
20
+ # layout. It only raises MissingTemplate when there is no layout by
21
+ # that name in any format.
22
+ layout = layout_name && find_layout(layout_name, locals.keys, [formats.first])
23
+ if layout.nil?
24
+ log_skipped_layout(layout_name) if layout_name
25
+ return super
26
+ end
27
+
28
+ body = ActiveSupport::Notifications.instrument(
29
+ "render_layout.action_view",
30
+ identifier: layout.identifier
31
+ ) do
32
+ render_json_layout(view, layout, locals) do |json|
33
+ ActiveSupport::Notifications.instrument(
34
+ "render_template.action_view",
35
+ identifier: template.identifier,
36
+ layout: layout.virtual_path,
37
+ locals: locals
38
+ ) do
39
+ template.render(view, locals.merge(json: json))
40
+ end
41
+ end
42
+ end
43
+
44
+ # render_json_layout hands back its buffer; the caller wants a String.
45
+ build_rendered_template(body.to_s, template)
46
+ end
47
+
48
+ # Renders a layout with a builder of our own, so `json.yield!` has something
49
+ # to place. Layout and template write to one encoder, so the yielded JSON
50
+ # lands where it is placed with its commas and nesting intact.
51
+ #
52
+ # `render_inner` renders the template the layout wraps, taking the builder
53
+ # to render into; the buffer, when given, is the streaming one both renders
54
+ # share. Shared with StreamingTurboTemplateRenderer, which reaches it
55
+ # through StreamingTemplateRenderer's superclass.
56
+ #
57
+ # The block exists only to give a bare `yield` a useful failure. Without one
58
+ # it would be `no block given (yield)`, which says nothing about layouts.
59
+ def render_json_layout(view, layout, locals, buffer = nil, &render_inner)
60
+ # ActionView::TurboBuffer in full: the compact `module
61
+ # ActionView::TurboTemplateRenderer` above does not nest ActionView
62
+ # lexically, so a bare TurboBuffer would not resolve.
63
+ json = TurboStreamer::Template.new(view, output_buffer: buffer || ActionView::TurboBuffer.new(String.new))
64
+ json.yield_content = render_inner
65
+
66
+ layout.render(view, locals.merge(json: json)) do |*|
67
+ raise ::LocalJumpError, '`yield` is not supported in a .json.streamer layout, use `json.yield!`'
68
+ end
69
+
70
+ json.target!
71
+ end
72
+
73
+ def log_skipped_layout(layout_name)
74
+ logger = ActionView::Base.logger
75
+ return unless logger
76
+
77
+ logger.debug do
78
+ " Skipped layout #{layout_name} -- it does not exist for " \
79
+ "#{formats.first.inspect}; rendering without a layout"
80
+ end
81
+ end
82
+
83
+ end
@@ -1,59 +1,49 @@
1
- dependency_tracker = false
2
-
3
- begin
4
- require 'action_view'
5
- require 'action_view/dependency_tracker'
6
- dependency_tracker = ::ActionView::DependencyTracker
7
- rescue LoadError
8
- begin
9
- require 'cache_digests'
10
- dependency_tracker = ::CacheDigests::DependencyTracker
11
- rescue LoadError
12
- end
13
- end
1
+ # frozen_string_literal: true
2
+
3
+ # TODO: maybe remove reliance on ERBTracker ala: https://github.com/rails/jbuilder/commit/710979958aa012f4ff21800abf5bd50b717df0a6
4
+ require 'action_view'
5
+ require 'action_view/dependency_tracker'
6
+
7
+ class TurboStreamer
8
+ module DependencyTrackerMethods
9
+ # Matches:
10
+ # json.partial! "messages/message"
11
+ # json.partial!('messages/message')
12
+ #
13
+ DIRECT_RENDERS = /
14
+ \w+\.partial! # json.partial!
15
+ \(?\s* # optional parenthesis
16
+ (['"])([^'"]+)\1 # quoted value
17
+ /x
18
+
19
+ # Matches:
20
+ # json.partial! partial: "comments/comment"
21
+ # json.comments @post.comments, partial: "comments/comment", as: :comment
22
+ # json.array! @posts, partial: "posts/post", as: :post
23
+ # = render partial: "account"
24
+ #
25
+ INDIRECT_RENDERS = /
26
+ (?::partial\s*=>|partial:) # partial: or :partial =>
27
+ \s* # optional whitespace
28
+ (['"])([^'"]+)\1 # quoted value
29
+ /x
30
+
31
+ def dependencies
32
+ direct_dependencies + indirect_dependencies + explicit_dependencies
33
+ end
34
+
35
+ private
14
36
 
15
- if dependency_tracker
16
- class TurboStreamer
17
- module DependencyTrackerMethods
18
- # Matches:
19
- # json.partial! "messages/message"
20
- # json.partial!('messages/message')
21
- #
22
- DIRECT_RENDERS = /
23
- \w+\.partial! # json.partial!
24
- \(?\s* # optional parenthesis
25
- (['"])([^'"]+)\1 # quoted value
26
- /x
27
-
28
- # Matches:
29
- # json.partial! partial: "comments/comment"
30
- # json.comments @post.comments, partial: "comments/comment", as: :comment
31
- # json.array! @posts, partial: "posts/post", as: :post
32
- # = render partial: "account"
33
- #
34
- INDIRECT_RENDERS = /
35
- (?::partial\s*=>|partial:) # partial: or :partial =>
36
- \s* # optional whitespace
37
- (['"])([^'"]+)\1 # quoted value
38
- /x
39
-
40
- def dependencies
41
- direct_dependencies + indirect_dependencies + explicit_dependencies
42
- end
43
-
44
- private
45
-
46
- def direct_dependencies
47
- source.scan(DIRECT_RENDERS).map(&:second)
48
- end
49
-
50
- def indirect_dependencies
51
- source.scan(INDIRECT_RENDERS).map(&:second)
52
- end
37
+ def direct_dependencies
38
+ source.scan(DIRECT_RENDERS).map(&:second)
53
39
  end
54
- end
55
40
 
56
- ::TurboStreamer::DependencyTracker = Class.new(dependency_tracker::ERBTracker)
57
- ::TurboStreamer::DependencyTracker.send :include, ::TurboStreamer::DependencyTrackerMethods
58
- dependency_tracker.register_tracker :streamer, ::TurboStreamer::DependencyTracker
41
+ def indirect_dependencies
42
+ source.scan(INDIRECT_RENDERS).map(&:second)
43
+ end
44
+ end
59
45
  end
46
+
47
+ ::TurboStreamer::DependencyTracker = Class.new(::ActionView::DependencyTracker::ERBTracker)
48
+ ::TurboStreamer::DependencyTracker.send :include, ::TurboStreamer::DependencyTrackerMethods
49
+ ::ActionView::DependencyTracker.register_tracker :streamer, ::TurboStreamer::DependencyTracker
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "oj"
2
4
 
3
5
  class TurboStreamer
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'wankel'
2
4
 
3
5
  class TurboStreamer
@@ -1,9 +1,10 @@
1
+ # frozen_string_literal: true
2
+
1
3
  class TurboStreamer
2
4
  module Errors
3
5
  class MergeError < ::StandardError
4
6
  def self.build(updates)
5
- message = "Can't merge #{updates.inspect} which isn't Hash or Array"
6
- new(message)
7
+ new("Can't merge #{updates.inspect} which isn't Hash or Array")
7
8
  end
8
9
  end
9
10
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "turbostreamer"
2
4
  require "active_support/core_ext"
3
5
 
@@ -15,8 +17,8 @@ class TurboStreamer
15
17
 
16
18
  def self.call(template, source)
17
19
  # this juggling is required to keep line numbers right in the error
18
- %{__already_defined = defined?(json); json||=TurboStreamer::Template.new(self, output_buffer: output_buffer || ActionView::OutputBuffer.new); #{source}
19
- json.target! unless (__already_defined && __already_defined != "method")}
20
+ %{__parent_json = local_assigns[:json]; json = __parent_json || TurboStreamer::Template.new(self, output_buffer: ActionView::TurboBuffer.wrap(output_buffer)); #{source}
21
+ json.target! unless __parent_json}
20
22
  end
21
23
 
22
24
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  class TurboStreamer::KeyFormatter
2
4
  def initialize(*args)
3
5
  @format = {}
@@ -1,3 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Rails. 8.1 requires this not sure why we have to load it and the railtie doesn't
4
+ require "active_support/core_ext/module/delegation"
5
+
1
6
  require 'rails/railtie'
2
7
 
3
8
  class TurboStreamer
@@ -6,18 +11,34 @@ class TurboStreamer
6
11
  ActiveSupport.on_load :action_view do
7
12
  # Require turbostreamer in here so it's only loaded if needed
8
13
  require 'turbostreamer'
9
- require File.expand_path('../../../ext/actionview/buffer', __FILE__)
10
- require File.expand_path('../../../ext/actionview/streaming_template_renderer', __FILE__)
14
+ require 'action_view/turbo_buffer'
15
+ require 'action_view/streaming_turbo_buffer'
16
+ require 'action_view/turbo_template_renderer'
17
+ require 'action_view/streaming_turbo_template_renderer'
18
+
19
+ ActionView::TemplateRenderer.prepend(ActionView::TurboTemplateRenderer)
20
+ ActionView::StreamingTemplateRenderer.prepend(ActionView::StreamingTurboTemplateRenderer)
11
21
 
12
22
  # Register Turbostreamer with Rails
13
23
  ActionView::Template.register_template_handler :streamer, TurboStreamer::Handler
14
24
 
15
- # Setup the default for oj to be rails mode by default unless options
16
- # have already been set
17
- if TurboStreamer.default_encoder_for(:json).name == 'TurboStreamer::OjEncoder'
18
- if !TurboStreamer.has_default_encoder_options?(:oj)
19
- TurboStreamer.set_default_encoder_options(:oj, mode: :rails)
20
- end
25
+ # Resolve the encoder once, here, the way an app resolves its cache
26
+ # store at boot. Left unset, default_encoder_for falls through to
27
+ # get_encoder on every render, and the `require` in there re-scans
28
+ # $LOAD_PATH each time.
29
+ encoder = TurboStreamer.default_encoder_for(:json)
30
+ TurboStreamer.set_default_encoder(:json, encoder)
31
+
32
+ # Oj's :rails mode is what escapes HTML entities the way
33
+ # ActiveSupport::JSON does, so a payload embedded in a <script> tag is
34
+ # safe. Merge rather than skip when options are already set: an app
35
+ # setting an unrelated one -- buffer_size, say -- would otherwise lose
36
+ # the mode along with the escaping, silently. Anything the app set
37
+ # explicitly still wins.
38
+ if encoder.name == 'TurboStreamer::OjEncoder'
39
+ TurboStreamer.set_default_encoder_options(
40
+ :oj, { mode: :rails }.merge(TurboStreamer.default_encoder_options(:oj))
41
+ )
21
42
  end
22
43
 
23
44
  require 'turbostreamer/dependency_tracker'
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'turbostreamer'
2
4
 
3
5
  class TurboStreamer::Template < TurboStreamer
@@ -12,6 +14,10 @@ class TurboStreamer::Template < TurboStreamer
12
14
  @context = context
13
15
  super(*args, &block)
14
16
  end
17
+
18
+ # The proc that renders the template this layout wraps, for json.yield! to
19
+ # place.
20
+ attr_accessor :yield_content
15
21
 
16
22
  def partial!(name_or_options, locals = {})
17
23
  if name_or_options.class.respond_to?(:model_name) && name_or_options.respond_to?(:to_partial_path)
@@ -35,6 +41,29 @@ class TurboStreamer::Template < TurboStreamer
35
41
  end
36
42
  end
37
43
 
44
+ # The same thing as a statement rather than a value, for a layout that would
45
+ # rather write the key itself:
46
+ #
47
+ # json.key! :data
48
+ # json.yield!
49
+ def yield!
50
+ content = @yield_content
51
+ # The same thing Ruby says for a `yield` with no block behind it, because
52
+ # that is what this is.
53
+ raise ::LocalJumpError, 'no block given (yield)' if content.nil?
54
+
55
+ # The template renders through this same builder, so it would otherwise
56
+ # still see the content and yield straight back into itself. Clear it while
57
+ # it renders -- a template has nothing to yield -- and put it back, so a
58
+ # layout can go on to yield again.
59
+ begin
60
+ @yield_content = nil
61
+ content.call(self)
62
+ ensure
63
+ @yield_content = content
64
+ end
65
+ end
66
+
38
67
  def array!(collection = BLANK, *attributes, &block)
39
68
  options = attributes.extract_options!
40
69
 
@@ -201,9 +230,6 @@ class TurboStreamer::Template < TurboStreamer
201
230
 
202
231
  if @context.respond_to?(:combined_fragment_cache_key)
203
232
  key = @context.combined_fragment_cache_key(key)
204
- elsif @context.respond_to?(:fragment_cache_key)
205
- # TODO: remove after droping rails 5.1 support
206
- key = @context.fragment_cache_key(key)
207
233
  elsif ::Hash === key
208
234
  key = url_for(key).split('://', 2).last
209
235
  end
@@ -224,17 +250,21 @@ class TurboStreamer::Template < TurboStreamer
224
250
  end
225
251
  end
226
252
 
227
- def _partial_options?(options)
228
- ::Hash === options && options.key?(:as) && options.key?(:partial)
229
- end
230
-
231
- def _is_active_model?(object)
232
- object.class.respond_to?(:model_name) && object.respond_to?(:to_partial_path)
233
- end
234
-
253
+ # The base rule, plus the case only Rails has: a nil collection rendered
254
+ # through a partial -- `json.comments nil, partial: 'comment/comment', as:
255
+ # :comment` -- has to reach array! to come out as [] rather than being
256
+ # treated as a single object to extract from.
257
+ #
258
+ # Written out rather than calling super, because child! runs this on every
259
+ # element and the second dispatch showed up. It stays here rather than moving
260
+ # into TurboStreamer because partial! is a Template method: in a plain
261
+ # builder the `:as` clause has nothing to route to, and would only turn
262
+ # `json.foo nil, as: :x` from a TypeError into [].
235
263
  def _eachable_arguments?(value, *args)
236
- return true if super
264
+ return true if value.respond_to?(:each) && !value.is_a?(Hash)
265
+
237
266
  options = args.last
238
267
  ::Hash === options && options.key?(:as)
239
268
  end
269
+
240
270
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  class TurboStreamer
2
- VERSION = '1.11.0'
4
+ VERSION = '2.0.0'
3
5
  end
data/lib/turbostreamer.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'stringio'
2
4
 
3
5
  class TurboStreamer
@@ -6,7 +8,7 @@ class TurboStreamer
6
8
  autoload :Template, 'turbostreamer/template'
7
9
  autoload :KeyFormatter, 'turbostreamer/key_formatter'
8
10
  autoload :Errors, 'turbostreamer/errors'
9
-
11
+
10
12
  BLANK = ::Object.new
11
13
 
12
14
  ENCODERS = {
@@ -32,7 +34,7 @@ class TurboStreamer
32
34
  @encoder_options = @@encoder_options[options[:encoder]]
33
35
  elsif options[:encoder].nil?
34
36
  @encoder = TurboStreamer.default_encoder_for(options[:mime] || :json)
35
- if encoder_symbol = ENCODERS[options[:mime] || :json].find { |k, v| v == @encoder.name.delete_prefix('TurboStreamer::').delete_suffix('Encoder') }&.first
37
+ if encoder_symbol = TurboStreamer.encoder_symbol_for(options[:mime] || :json, @encoder)
36
38
  @encoder_options = @@encoder_options[encoder_symbol]
37
39
  else
38
40
  @encoder_options = {}
@@ -97,11 +99,7 @@ class TurboStreamer
97
99
  #
98
100
  # { "name": David", "age": 32 }, { "name": Jamie", "age": 31 }
99
101
  def extract!(object, *attributes)
100
- if ::Hash === object
101
- attributes.each{ |key| _set_value key, object.fetch(key) }
102
- else
103
- attributes.each{ |key| _set_value key, object.public_send(key) }
104
- end
102
+ _extract(object, attributes)
105
103
  end
106
104
 
107
105
  # Turns the current element into an array and iterates over the passed
@@ -167,7 +165,7 @@ class TurboStreamer
167
165
  else
168
166
  # json.author @post.creator, :name, :email_address
169
167
  # { "author": { "name": "David", "email_address": "david@thinking.com" } }
170
- object!{ extract!(value, *args) }
168
+ object!{ _extract(value, args) }
171
169
  end
172
170
  end
173
171
 
@@ -231,6 +229,15 @@ class TurboStreamer
231
229
  @@key_formatter = formatter
232
230
  end
233
231
 
232
+ # The symbol an encoder is known by, given either the symbol itself or the
233
+ # class. Options are always keyed by symbol, since that is what a builder has
234
+ # to look them up with.
235
+ def self.encoder_symbol_for(mime, encoder)
236
+ return encoder if encoder.is_a?(Symbol)
237
+
238
+ ENCODERS[mime]&.key(encoder.name.delete_prefix('TurboStreamer::').delete_suffix('Encoder'))
239
+ end
240
+
234
241
  def self.set_default_encoder(mime, encoder, default_options=nil)
235
242
  @@default_encoders[mime] = if encoder.is_a?(Symbol)
236
243
  get_encoder(mime, encoder)
@@ -238,20 +245,41 @@ class TurboStreamer
238
245
  encoder
239
246
  end
240
247
 
241
- @@encoder_options[encoder] = default_options if default_options
248
+ # Keyed by symbol even when handed a class -- keying by the class stored
249
+ # options a builder would never find, dropping them silently.
250
+ if default_options
251
+ @@encoder_options[encoder_symbol_for(mime, encoder) || encoder] = default_options
252
+ end
242
253
  end
243
-
254
+
244
255
  def self.set_default_encoder_options(encoder, options)
245
256
  @@encoder_options[encoder] = options
246
257
  end
247
-
258
+
259
+ # Does not use [] -- @@encoder_options defaults new keys to {} *and assigns
260
+ # them*, so reading through it would make has_default_encoder_options? true.
261
+ def self.default_encoder_options(encoder)
262
+ @@encoder_options.fetch(encoder, {})
263
+ end
264
+
248
265
  def self.has_default_encoder_options?(encoder)
249
266
  @@encoder_options.has_key?(encoder)
250
267
  end
251
268
 
269
+ # Memoized because `require` with a relative feature name re-scans $LOAD_PATH
270
+ # on every call, stat-ing each entry, even once the file is loaded. This runs
271
+ # per render whenever no default encoder has been set -- which is the case in
272
+ # a Rails app, since the railtie only sets encoder options -- and the stat
273
+ # storm dwarfed the encoding itself.
274
+ #
275
+ # A [mime, key] pair always names the same class, so caching it does not
276
+ # affect set_default_encoder: default_encoder_for still reads @@default_encoders.
252
277
  def self.get_encoder(mime, key)
253
- require "turbostreamer/encoders/#{key}"
254
- Object.const_get("TurboStreamer::#{ENCODERS[mime][key]}Encoder")
278
+ @encoders ||= {}
279
+ @encoders[[mime, key]] ||= begin
280
+ require "turbostreamer/encoders/#{key}"
281
+ Object.const_get("TurboStreamer::#{ENCODERS[mime][key]}Encoder")
282
+ end
255
283
  end
256
284
 
257
285
  def self.default_encoder_for(mime)
@@ -325,7 +353,7 @@ class TurboStreamer
325
353
  elsif _eachable_arguments?(value, *args)
326
354
  _scope{ array!(value, *args) }
327
355
  else
328
- object!{ extract!(value, *args) }
356
+ object!{ _extract(value, args) }
329
357
  end
330
358
 
331
359
  end
@@ -334,15 +362,25 @@ class TurboStreamer
334
362
  def target!
335
363
  @encoder.flush
336
364
 
337
- if @encoder.output.is_a?(::StringIO)
338
- @encoder.output.string
365
+ output = @encoder.output
366
+
367
+ if output.is_a?(::StringIO)
368
+ output.string
339
369
  else
340
- @encoder.output
370
+ output
341
371
  end
342
372
  end
343
373
 
344
374
  private
345
375
 
376
+ def _extract(object, attributes)
377
+ if ::Hash === object
378
+ attributes.each{ |key| _set_value key, object.fetch(key) }
379
+ else
380
+ attributes.each{ |key| _set_value key, object.public_send(key) }
381
+ end
382
+ end
383
+
346
384
  def _write(key, value)
347
385
  @encoder.key(_key(key))
348
386
  @encoder.value(value)
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: turbostreamer
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.11.0
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jon Bracy
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2024-04-29 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: activesupport
@@ -16,14 +15,14 @@ dependencies:
16
15
  requirements:
17
16
  - - ">="
18
17
  - !ruby/object:Gem::Version
19
- version: 5.0.0
18
+ version: 8.0.0
20
19
  type: :runtime
21
20
  prerelease: false
22
21
  version_requirements: !ruby/object:Gem::Requirement
23
22
  requirements:
24
23
  - - ">="
25
24
  - !ruby/object:Gem::Version
26
- version: 5.0.0
25
+ version: 8.0.0
27
26
  - !ruby/object:Gem::Dependency
28
27
  name: rake
29
28
  requirement: !ruby/object:Gem::Requirement
@@ -80,20 +79,6 @@ dependencies:
80
79
  - - ">="
81
80
  - !ruby/object:Gem::Version
82
81
  version: '0'
83
- - !ruby/object:Gem::Dependency
84
- name: bundler
85
- requirement: !ruby/object:Gem::Requirement
86
- requirements:
87
- - - ">="
88
- - !ruby/object:Gem::Version
89
- version: '0'
90
- type: :development
91
- prerelease: false
92
- version_requirements: !ruby/object:Gem::Requirement
93
- requirements:
94
- - - ">="
95
- - !ruby/object:Gem::Version
96
- version: '0'
97
82
  - !ruby/object:Gem::Dependency
98
83
  name: mocha
99
84
  requirement: !ruby/object:Gem::Requirement
@@ -123,7 +108,7 @@ dependencies:
123
108
  - !ruby/object:Gem::Version
124
109
  version: '0'
125
110
  - !ruby/object:Gem::Dependency
126
- name: byebug
111
+ name: debug
127
112
  requirement: !ruby/object:Gem::Requirement
128
113
  requirements:
129
114
  - - ">="
@@ -164,62 +149,6 @@ dependencies:
164
149
  - - ">="
165
150
  - !ruby/object:Gem::Version
166
151
  version: '0'
167
- - !ruby/object:Gem::Dependency
168
- name: analyzer
169
- requirement: !ruby/object:Gem::Requirement
170
- requirements:
171
- - - ">="
172
- - !ruby/object:Gem::Version
173
- version: '0'
174
- type: :development
175
- prerelease: false
176
- version_requirements: !ruby/object:Gem::Requirement
177
- requirements:
178
- - - ">="
179
- - !ruby/object:Gem::Version
180
- version: '0'
181
- - !ruby/object:Gem::Dependency
182
- name: jbuilder
183
- requirement: !ruby/object:Gem::Requirement
184
- requirements:
185
- - - ">="
186
- - !ruby/object:Gem::Version
187
- version: '0'
188
- type: :development
189
- prerelease: false
190
- version_requirements: !ruby/object:Gem::Requirement
191
- requirements:
192
- - - ">="
193
- - !ruby/object:Gem::Version
194
- version: '0'
195
- - !ruby/object:Gem::Dependency
196
- name: rabl
197
- requirement: !ruby/object:Gem::Requirement
198
- requirements:
199
- - - ">="
200
- - !ruby/object:Gem::Version
201
- version: '0'
202
- type: :development
203
- prerelease: false
204
- version_requirements: !ruby/object:Gem::Requirement
205
- requirements:
206
- - - ">="
207
- - !ruby/object:Gem::Version
208
- version: '0'
209
- - !ruby/object:Gem::Dependency
210
- name: appraisal
211
- requirement: !ruby/object:Gem::Requirement
212
- requirements:
213
- - - "~>"
214
- - !ruby/object:Gem::Version
215
- version: '2.0'
216
- type: :development
217
- prerelease: false
218
- version_requirements: !ruby/object:Gem::Requirement
219
- requirements:
220
- - - "~>"
221
- - !ruby/object:Gem::Version
222
- version: '2.0'
223
152
  - !ruby/object:Gem::Dependency
224
153
  name: railties
225
154
  requirement: !ruby/object:Gem::Requirement
@@ -234,23 +163,9 @@ dependencies:
234
163
  - - ">="
235
164
  - !ruby/object:Gem::Version
236
165
  version: '0'
237
- - !ruby/object:Gem::Dependency
238
- name: multi_json
239
- requirement: !ruby/object:Gem::Requirement
240
- requirements:
241
- - - ">="
242
- - !ruby/object:Gem::Version
243
- version: '0'
244
- type: :development
245
- prerelease: false
246
- version_requirements: !ruby/object:Gem::Requirement
247
- requirements:
248
- - - ">="
249
- - !ruby/object:Gem::Version
250
- version: '0'
251
- description: |2
252
- TurboStreamer is a JBuilder-like DSL for building JSON that streams directly
253
- to a string or IO
166
+ description: |
167
+ TurboStreamer is a JBuilder-like DSL for building
168
+ JSON that streams directly to a string or IO
254
169
  email:
255
170
  - jonbracy@gmail.com
256
171
  executables: []
@@ -258,9 +173,13 @@ extensions: []
258
173
  extra_rdoc_files:
259
174
  - README.md
260
175
  files:
176
+ - CHANGELOG.md
177
+ - LICENSE
261
178
  - README.md
262
- - ext/actionview/buffer.rb
263
- - ext/actionview/streaming_template_renderer.rb
179
+ - lib/action_view/streaming_turbo_buffer.rb
180
+ - lib/action_view/streaming_turbo_template_renderer.rb
181
+ - lib/action_view/turbo_buffer.rb
182
+ - lib/action_view/turbo_template_renderer.rb
264
183
  - lib/turbostreamer.rb
265
184
  - lib/turbostreamer/dependency_tracker.rb
266
185
  - lib/turbostreamer/encoders/oj.rb
@@ -274,8 +193,11 @@ files:
274
193
  homepage: https://github.com/malomalo/turbostreamer
275
194
  licenses:
276
195
  - MIT
277
- metadata: {}
278
- post_install_message:
196
+ metadata:
197
+ source_code_uri: https://github.com/malomalo/turbostreamer
198
+ bug_tracker_uri: https://github.com/malomalo/turbostreamer/issues
199
+ changelog_uri: https://github.com/malomalo/turbostreamer/blob/master/CHANGELOG.md
200
+ rubygems_mfa_required: 'true'
279
201
  rdoc_options:
280
202
  - "--main"
281
203
  - README.md
@@ -285,15 +207,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
285
207
  requirements:
286
208
  - - ">="
287
209
  - !ruby/object:Gem::Version
288
- version: '3.0'
210
+ version: 3.3.0
289
211
  required_rubygems_version: !ruby/object:Gem::Requirement
290
212
  requirements:
291
213
  - - ">="
292
214
  - !ruby/object:Gem::Version
293
215
  version: '0'
294
216
  requirements: []
295
- rubygems_version: 3.5.4
296
- signing_key:
217
+ rubygems_version: 4.0.11
297
218
  specification_version: 4
298
219
  summary: Stream JSON via a Builder-style DSL
299
220
  test_files: []
@@ -1,25 +0,0 @@
1
- module ActionView
2
- class OutputBuffer
3
- alias :write :safe_concat
4
- end
5
-
6
- class StreamingBuffer #:nodoc:
7
- alias :write :safe_concat
8
- end
9
-
10
- class JSONStreamingBuffer #:nodoc:
11
- def initialize(block)
12
- @block = block
13
- end
14
-
15
- def <<(value)
16
- @block.call(value.to_s)
17
- end
18
- alias :write :<<
19
- alias :concat :<<
20
- alias :append= :<<
21
- alias :safe_concat :<<
22
- alias :safe_append= :<<
23
- end
24
-
25
- end
@@ -1,41 +0,0 @@
1
- module ActionView
2
- class StreamingTemplateRenderer < TemplateRenderer
3
-
4
- def render_template(view, template, layout_name = nil, locals = {}) #:nodoc:
5
- template_supports_streaming = (layout_name && template.supports_streaming?) || template.handler == TurboStreamer::Handler
6
- return [super.body] unless layout_name && template_supports_streaming
7
-
8
- locals ||= {}
9
- layout = layout_name && find_layout(layout_name, locals.keys, [formats.first])
10
-
11
- Body.new do |buffer|
12
- if template.handler == TurboStreamer::Handler
13
- delayed_render_json(buffer, template, layout, view, locals)
14
- else
15
- delayed_render(buffer, template, layout, view, locals)
16
- end
17
- end
18
- end
19
-
20
- private
21
-
22
- def delayed_render_json(buffer, template, layout, view, locals)
23
- # Wrap the given buffer in the StreamingBuffer and pass it to the
24
- # underlying template handler. Now, every time something is concatenated
25
- # to the buffer, it is not appended to an array, but streamed straight
26
- # to the client.
27
- output = ActionView::JSONStreamingBuffer.new(buffer)
28
- yielder = lambda { |*name| view._layout_for(*name) }
29
-
30
- instrument(:template, identifier: template.identifier, layout: layout.try(:virtual_path)) do
31
- fiber = Fiber.new do
32
- template.render(view, locals, output, &yielder)
33
- end
34
-
35
- fiber.resume
36
- end
37
-
38
- end
39
-
40
- end
41
- end