response_bank 1.7.0 → 1.9.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: 54fd4c438eab18c36f842002725ec243f6ca9837f03a2d70dc594a8bf4765d8d
4
- data.tar.gz: cc5512b2fa88dd622da3ef9fae6abda48f9c7122eed828dd988a17343607f0cd
3
+ metadata.gz: bd0398adb597c3f34c02aa07738d644635eb04e349f58182b9c7795880292eba
4
+ data.tar.gz: 27d93896f30f2d6fff34376b5b93439cb0f1737fb89909734089090dcaf36f66
5
5
  SHA512:
6
- metadata.gz: 9637541060aab3ac047247fdd13e83d57b99e3921689a7dd354ce684c247f9decb04de2c784e6f04bb57796e71279098a2f1a3db670d67ad25bb8406a7eb30f3
7
- data.tar.gz: d3ba8c122b58152cd1e10e8d1fb504b65c2e8c906d3a862be98050f7b252129d392bbc83d0e220a2c79248f0da16bb3f85d8ae441c0c2b03281ee123a872b4bf
6
+ metadata.gz: ce162be922a3ab92c0ddc5629f27d6d373a6bf4abe01d6b95de9b48035eac46ee5d40aa00eadfa35824b0cd61e39d16546aeec63bd128c83bc79a06253fc75a0
7
+ data.tar.gz: 30b7df7e16c07e5e3427d042d100904a6ad05456a9e3bda630a01530f188acda3731d447dba6d22c5cbce1c7cdb50082a7fd0dee11d43a7052d64e2a5c3fe17e
data/README.md CHANGED
@@ -204,6 +204,12 @@ config.response_bank.brotli_splice_injector =
204
204
 
205
205
  The injector is optional. If it is not configured, ResponseBank uses the normal Brotli compression path. Applications own the concrete injector implementation because they know how to read their request-specific metadata.
206
206
 
207
+ Splicing needs the native `brotli_splice` gem, version 0.3.0 or newer. It is not a runtime dependency of ResponseBank, so add it to your application's Gemfile:
208
+
209
+ ```ruby
210
+ gem 'brotli_splice', '>= 0.3.0'
211
+ ```
212
+
207
213
  Injectors may include `ResponseBank::BrotliSpliceInjector` to document the required methods:
208
214
 
209
215
  ```ruby
@@ -227,7 +233,7 @@ class HtmlMetadataInjector
227
233
 
228
234
  def prepare_response_bank_brotli_splice(body, _headers)
229
235
  body_with_placeholder = body.sub("</head>", "#{PLACEHOLDER_TAG}</head>")
230
- # Use a byte offset: BrotliSplice.encode addresses the slot by bytes.
236
+ # Use a byte offset: BrotliSplice addresses slots by bytes.
231
237
  offset = body_with_placeholder.b.index(TOKEN_PLACEHOLDER)
232
238
  return unless offset
233
239
 
@@ -280,24 +286,33 @@ end
280
286
  ```
281
287
 
282
288
  `prepare_response_bank_brotli_splice` is used on cache writes. It returns HTML
283
- containing a neutral placeholder and one slot describing that placeholder.
284
- ResponseBank stores the slot metadata with the cached Brotli body. The slot's
285
- `offset` and `length` are **byte** offsets/counts — `BrotliSplice.encode`
286
- addresses the stream by bytes — so locate the placeholder on a binary view
289
+ containing neutral placeholders and one slot describing each placeholder, in
290
+ ascending byte order; slots may touch but must not overlap. ResponseBank stores
291
+ one metadata entry per slot with the cached Brotli body. Each slot's
292
+ `offset` and `length` are **byte** offsets/counts — BrotliSplice addresses the
293
+ stream by bytes — so locate the placeholder on a binary view
287
294
  (`body.b.index(...)`) and size it with `bytesize`. A plain `String#index`/`size`
288
295
  silently breaks once any multi-byte UTF-8 precedes the placeholder: a character
289
296
  offset is always ≤ the byte length, so the bounds check still passes.
290
-
291
- `response_bank_brotli_splice_replacement` is used on Brotli cache hits. It must
292
- return exactly `slot["replacement_length"]` bytes. Note that `replacement_length`
293
- is **2 fewer** than the `length` you registered in the slot: `BrotliSplice.encode`
294
- reserves the last 2 bytes of every slot as a fixed `\r\n` context suffix. So size
295
- your placeholder to include those 2 bytes (as the example does with the trailing
296
- `\r\n`), and have the replacement match `replacement_length`. If the byte length
297
- does not match, ResponseBank **silently skips the splice and serves the neutral
298
- placeholder** — no exception is raised — so guard the length instead of assuming it.
299
-
300
- `replace_response_bank_brotli_splice_placeholders` is used when a cached Brotli response is decompressed for a client that does not accept Brotli.
297
+ If any slot cannot be encoded (out of bounds, out of order, overlapping, or no
298
+ longer than the 2 reserved bytes), ResponseBank logs it and caches the returned
299
+ HTML without slots, so the response is never cached as rendered. On that
300
+ request, a client that accepts Brotli is served the returned HTML too,
301
+ placeholders included. A Hash without `:body` raises `ArgumentError`, and
302
+ nothing is cached (see [Exception Handling](#exception-handling)). Return `nil`
303
+ to cache the body as rendered.
304
+
305
+ `response_bank_brotli_splice_replacement` is used on Brotli cache hits, once per
306
+ slot. It must return exactly `slot["replacement_length"]` bytes. Note that
307
+ `replacement_length` is **2 fewer** than the `length` you registered in the slot:
308
+ BrotliSplice reserves the last 2 bytes of every slot as a fixed `\r\n` context
309
+ suffix. So size your placeholder to include those 2 bytes (as the example does
310
+ with the trailing `\r\n`), and have the replacement match `replacement_length`.
311
+ If the byte length does not match, ResponseBank **silently skips that slot,
312
+ which keeps its neutral placeholder** — no exception is raised — so guard the
313
+ length instead of assuming it. The other slots are still filled.
314
+
315
+ `replace_response_bank_brotli_splice_placeholders` is used when a cached Brotli response is decompressed for a client that does not accept Brotli. It receives every slot.
301
316
 
302
317
  Advanced integrations can still install the per-request injector directly in the Rack env before ResponseBank reads or writes the cached body:
303
318
 
@@ -7,12 +7,17 @@ module ResponseBank
7
7
  METADATA_VERSION = 1
8
8
  # BrotliSplice reserves the last 2 bytes of every slot as a fixed "\r\n" context
9
9
  # suffix, so a slot must be longer than that to hold any replaceable content --
10
- # BrotliSplice.encode raises unless the slot length exceeds it.
10
+ # BrotliSplice.encode_slots raises unless every slot length exceeds it.
11
11
  CONTEXT_SUFFIX_LENGTH = 2
12
12
 
13
13
  EncodedBody = Struct.new(:body, :compressed_body, :metadata, keyword_init: true)
14
14
 
15
15
  class << self
16
+ # Returns nil when the injector does not prepare the body. Once it does, the
17
+ # prepared body is the shared copy, so the response is never cached as rendered:
18
+ # without a prepared body this raises, and nothing is cached; if its slots cannot
19
+ # be encoded, the result carries that body without a compressed body or metadata,
20
+ # so the caller compresses it normally.
16
21
  def encode_body(env, body, headers, compression_level:)
17
22
  injector = env[INJECTOR_ENV_KEY]
18
23
  return unless injector
@@ -22,14 +27,13 @@ module ResponseBank
22
27
  return unless prepared
23
28
 
24
29
  prepared_body = hash_fetch(prepared, :body)
25
- slots = hash_fetch(prepared, :slots)
26
- return unless prepared_body && slots && slots.length == 1
30
+ raise ArgumentError, 'prepare_response_bank_brotli_splice returned no :body' unless prepared_body
27
31
 
28
- slot = slots.first
29
- slot_name = hash_fetch(slot, :name).to_s
30
- html_offset = integer_value(hash_fetch(slot, :offset))
31
- html_length = integer_value(hash_fetch(slot, :length))
32
- return unless valid_html_slot?(prepared_body, html_offset, html_length)
32
+ slots = html_slots(prepared_body, hash_fetch(prepared, :slots))
33
+ unless slots
34
+ ResponseBank.log('BrotliSplice encode skipped: invalid slot')
35
+ return EncodedBody.new(body: prepared_body)
36
+ end
33
37
 
34
38
  # Load the native gem only once we have real splice work to do. Keeping this
35
39
  # outside the begin/rescue below is deliberate: the rescue clause references
@@ -38,45 +42,38 @@ module ResponseBank
38
42
  ensure_brotli_splice_loaded!
39
43
 
40
44
  begin
41
- result = BrotliSplice.encode(prepared_body, html_offset, html_length, quality: compression_level)
45
+ result = BrotliSplice.encode_slots(
46
+ prepared_body,
47
+ slots.map { |slot| [slot[:offset], slot[:length]] },
48
+ quality: compression_level,
49
+ )
50
+ compressed_slots = slots.zip(result[:slots]).map do |slot, compressed_slot|
51
+ {
52
+ name: slot[:name],
53
+ compressed_offset: compressed_slot[:offset],
54
+ replacement_length: compressed_slot[:length],
55
+ html_placeholder_offset: slot[:offset],
56
+ html_placeholder_length: slot[:length],
57
+ context_suffix: result[:context_suffix],
58
+ }
59
+ end
42
60
 
43
61
  EncodedBody.new(
44
62
  body: prepared_body,
45
63
  compressed_body: result[:data],
46
- metadata: metadata_for(
47
- name: slot_name,
48
- compressed_offset: result[:secret_offset],
49
- replacement_length: result[:secret_length],
50
- html_placeholder_offset: html_offset,
51
- html_placeholder_length: html_length,
52
- context_suffix: result[:context_suffix],
53
- ),
64
+ metadata: metadata_for(compressed_slots),
54
65
  )
55
66
  rescue BrotliSplice::Error, ArgumentError => error
56
67
  ResponseBank.log("BrotliSplice encode skipped: #{error.class}")
57
- nil
68
+ EncodedBody.new(body: prepared_body)
58
69
  end
59
70
  end
60
71
 
61
- def metadata_for(
62
- name:,
63
- compressed_offset:,
64
- replacement_length:,
65
- html_placeholder_offset:,
66
- html_placeholder_length:,
67
- context_suffix:
68
- )
72
+ def metadata_for(slots)
69
73
  {
70
74
  METADATA_KEY => {
71
75
  'version' => METADATA_VERSION,
72
- 'slots' => [{
73
- 'name' => name.to_s,
74
- 'compressed_offset' => compressed_offset,
75
- 'replacement_length' => replacement_length,
76
- 'html_placeholder_offset' => html_placeholder_offset,
77
- 'html_placeholder_length' => html_placeholder_length,
78
- 'context_suffix' => context_suffix,
79
- }],
76
+ 'slots' => slots.map { |slot| slot_metadata(**slot) },
80
77
  },
81
78
  }
82
79
  end
@@ -91,28 +88,12 @@ module ResponseBank
91
88
  ensure_brotli_splice_loaded!
92
89
 
93
90
  begin
94
- slots.reduce(body) do |current_body, slot|
95
- replacement = replacement_for_slot(injector, slot)
96
-
97
- unless valid_replacement?(replacement, slot)
98
- got = replacement ? replacement.bytesize : 'nil'
99
- ResponseBank.log(
100
- 'BrotliSplice replace skipped: replacement length mismatch ' \
101
- "(got #{got}, want #{slot['replacement_length']})",
102
- )
103
- next current_body
104
- end
105
-
106
- offset = integer_value(slot['compressed_offset'])
107
- length = integer_value(slot['replacement_length'])
108
-
109
- unless valid_compressed_slot?(current_body, offset, length)
110
- ResponseBank.log('BrotliSplice replace skipped: compressed slot out of bounds')
111
- next current_body
112
- end
113
-
114
- BrotliSplice.replace(current_body, replacement, offset, length)
115
- end
91
+ # A slot that cannot be filled keeps its neutral placeholder; the others are
92
+ # still written, with a single copy of the body.
93
+ replacements = slots.filter_map { |slot| compressed_replacement(injector, body, slot) }
94
+ return body if replacements.empty?
95
+
96
+ BrotliSplice.replace_slots(body, replacements)
116
97
  rescue BrotliSplice::Error, ArgumentError => error
117
98
  ResponseBank.log("BrotliSplice replace skipped: #{error.class}")
118
99
  body
@@ -149,11 +130,11 @@ module ResponseBank
149
130
  return if @brotli_splice_loaded
150
131
 
151
132
  begin
152
- gem('brotli_splice')
133
+ gem('brotli_splice', '>= 0.3.0')
153
134
  require('brotli_splice')
154
135
  rescue LoadError => error
155
136
  warn(
156
- 'The Brotli splice slot feature requires the "brotli_splice" gem. ' \
137
+ 'The Brotli splice slot feature requires the "brotli_splice" gem, version 0.3.0 or newer. ' \
157
138
  'Add it to your application Gemfile.',
158
139
  )
159
140
  raise error
@@ -162,6 +143,62 @@ module ResponseBank
162
143
  @brotli_splice_loaded = true
163
144
  end
164
145
 
146
+ def slot_metadata(
147
+ name:,
148
+ compressed_offset:,
149
+ replacement_length:,
150
+ html_placeholder_offset:,
151
+ html_placeholder_length:,
152
+ context_suffix:
153
+ )
154
+ {
155
+ 'name' => name.to_s,
156
+ 'compressed_offset' => compressed_offset,
157
+ 'replacement_length' => replacement_length,
158
+ 'html_placeholder_offset' => html_placeholder_offset,
159
+ 'html_placeholder_length' => html_placeholder_length,
160
+ 'context_suffix' => context_suffix,
161
+ }
162
+ end
163
+
164
+ # Returns the injector's slots with Integer byte ranges, or nil unless there is
165
+ # at least one slot and every range fits the body. BrotliSplice.encode_slots
166
+ # checks that the ranges are ascending and do not overlap.
167
+ def html_slots(body, slots)
168
+ return unless slots.is_a?(Array) && !slots.empty?
169
+
170
+ slots.map do |slot|
171
+ offset = integer_value(hash_fetch(slot, :offset))
172
+ length = integer_value(hash_fetch(slot, :length))
173
+ return unless valid_html_slot?(body, offset, length)
174
+
175
+ { name: hash_fetch(slot, :name).to_s, offset: offset, length: length }
176
+ end
177
+ end
178
+
179
+ def compressed_replacement(injector, body, slot)
180
+ replacement = replacement_for_slot(injector, slot)
181
+
182
+ unless valid_replacement?(replacement, slot)
183
+ got = replacement ? replacement.bytesize : 'nil'
184
+ ResponseBank.log(
185
+ 'BrotliSplice replace skipped: replacement length mismatch ' \
186
+ "(got #{got}, want #{slot['replacement_length']})",
187
+ )
188
+ return
189
+ end
190
+
191
+ offset = integer_value(slot['compressed_offset'])
192
+ length = integer_value(slot['replacement_length'])
193
+
194
+ unless valid_compressed_slot?(body, offset, length)
195
+ ResponseBank.log('BrotliSplice replace skipped: compressed slot out of bounds')
196
+ return
197
+ end
198
+
199
+ [replacement, offset, length]
200
+ end
201
+
165
202
  def replacement_for_slot(injector, slot)
166
203
  injector.response_bank_brotli_splice_replacement(slot)
167
204
  end
@@ -54,7 +54,7 @@ module ResponseBank
54
54
  before_write: nil
55
55
  )
56
56
  validate_spliced_body!(env, body)
57
- metadata = slot && BrotliSpliceSlot.metadata_for(**slot)
57
+ metadata = slot && BrotliSpliceSlot.metadata_for([slot])
58
58
  env['cacheable.compression_level'] = compression_level
59
59
  persist(
60
60
  env,
@@ -124,13 +124,12 @@ module ResponseBank
124
124
  body = encoded_body.body
125
125
  body_compressed = encoded_body.compressed_body
126
126
  metadata = encoded_body.metadata
127
- else
128
- body_compressed = ResponseBank.compress(
129
- body,
130
- content_encoding,
131
- compression_level: compression_level,
132
- )
133
127
  end
128
+ body_compressed ||= ResponseBank.compress(
129
+ body,
130
+ content_encoding,
131
+ compression_level: compression_level,
132
+ )
134
133
  end
135
134
  ResponseBank.log("Compression time: #{time}ms")
136
135
  env['cacheable.compression_time'] = time
@@ -1,4 +1,4 @@
1
1
  # frozen_string_literal: true
2
2
  module ResponseBank
3
- VERSION = "1.7.0"
3
+ VERSION = "1.9.0"
4
4
  end
data/lib/response_bank.rb CHANGED
@@ -114,16 +114,20 @@ module ResponseBank
114
114
  when Hash
115
115
  return data.inspect unless data.key?(:key)
116
116
 
117
- key = hash_value_str(data[:key])
117
+ # Components are joined as bytes, so values in different encodings can't raise.
118
+ key = String.new(encoding: Encoding::BINARY)
118
119
 
119
- key = %{#{data[:key_schema_version]}:#{key}} if data[:key_schema_version]
120
+ key << hash_value_str(data[:key_schema_version]) << ":" if data[:key_schema_version]
120
121
 
121
- key = %{#{key}:#{hash_value_str(data[:version])}} if data[:version]
122
+ key << hash_value_str(data[:key])
123
+
124
+ key << ":" << hash_value_str(data[:version]) if data[:version]
122
125
 
123
126
  # add the encoding to only the cache key but don't expose this detail in the entity_tag
124
- key = %{#{key}:#{hash_value_str(data[:encoding])}} if data[:encoding]
127
+ key << ":" << hash_value_str(data[:encoding]) if data[:encoding]
125
128
 
126
- key
129
+ key.force_encoding(Encoding::UTF_8)
130
+ key.valid_encoding? ? key : key.force_encoding(Encoding::BINARY)
127
131
  when Array
128
132
  data.inspect
129
133
  when Time, DateTime
@@ -152,10 +156,32 @@ module ResponseBank
152
156
 
153
157
  def hash_value_str(data)
154
158
  if data.is_a?(Hash)
155
- data.values.join(",")
159
+ append_key_component(String.new(encoding: Encoding::BINARY), data)
160
+ else
161
+ data.to_s.b
162
+ end
163
+ end
164
+
165
+ # Hashes and Arrays are written as a type tag and element count, followed
166
+ # by their elements; Hash entries are written as name then value, in
167
+ # insertion order. Every other value is written as the byte length of its
168
+ # #to_s followed by those bytes, so no value can be read as part of another.
169
+ def append_key_component(buffer, data)
170
+ case data
171
+ when Hash
172
+ buffer << "h" << data.size.to_s << ":"
173
+ data.each do |name, value|
174
+ append_key_component(buffer, name)
175
+ append_key_component(buffer, value)
176
+ end
177
+ when Array
178
+ buffer << "a" << data.size.to_s << ":"
179
+ data.each { |value| append_key_component(buffer, value) }
156
180
  else
157
- data.to_s
181
+ value = data.to_s.b
182
+ buffer << value.bytesize.to_s << ":" << value
158
183
  end
184
+ buffer
159
185
  end
160
186
  end
161
187
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: response_bank
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.7.0
4
+ version: 1.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tobias Lütke
@@ -58,14 +58,14 @@ dependencies:
58
58
  requirements:
59
59
  - - '='
60
60
  - !ruby/object:Gem::Version
61
- version: 0.2.0
61
+ version: 0.3.0
62
62
  type: :development
63
63
  prerelease: false
64
64
  version_requirements: !ruby/object:Gem::Requirement
65
65
  requirements:
66
66
  - - '='
67
67
  - !ruby/object:Gem::Version
68
- version: 0.2.0
68
+ version: 0.3.0
69
69
  - !ruby/object:Gem::Dependency
70
70
  name: minitest
71
71
  requirement: !ruby/object:Gem::Requirement
@@ -176,7 +176,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
176
176
  - !ruby/object:Gem::Version
177
177
  version: '0'
178
178
  requirements: []
179
- rubygems_version: 4.0.21
179
+ rubygems_version: 4.0.23
180
180
  specification_version: 4
181
181
  summary: Simple response caching for Ruby applications
182
182
  test_files: []