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 +4 -4
- data/README.md +31 -16
- data/lib/response_bank/brotli_splice_slot.rb +95 -58
- data/lib/response_bank/cache_writer.rb +6 -7
- data/lib/response_bank/version.rb +1 -1
- data/lib/response_bank.rb +33 -7
- metadata +4 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bd0398adb597c3f34c02aa07738d644635eb04e349f58182b9c7795880292eba
|
|
4
|
+
data.tar.gz: 27d93896f30f2d6fff34376b5b93439cb0f1737fb89909734089090dcaf36f66
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
`
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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(
|
|
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
|
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
|
-
|
|
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
|
|
120
|
+
key << hash_value_str(data[:key_schema_version]) << ":" if data[:key_schema_version]
|
|
120
121
|
|
|
121
|
-
key
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
179
|
+
rubygems_version: 4.0.23
|
|
180
180
|
specification_version: 4
|
|
181
181
|
summary: Simple response caching for Ruby applications
|
|
182
182
|
test_files: []
|