response_bank 1.3.6 → 1.3.8
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 +119 -1
- data/lib/response_bank/brotli_splice_injector.rb +17 -0
- data/lib/response_bank/brotli_splice_slot.rb +184 -0
- data/lib/response_bank/middleware.rb +52 -4
- data/lib/response_bank/railtie.rb +10 -2
- data/lib/response_bank/response_cache_handler.rb +12 -4
- data/lib/response_bank/version.rb +1 -1
- data/lib/response_bank.rb +32 -4
- metadata +19 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f9c672021efe766d0fc33677c57dd7b1b1c4dc83c1a88824f40919722111b99a
|
|
4
|
+
data.tar.gz: f1215de9f225b7ea78d71cc7c815d7cff0811a737e3e9df4f686261e07339db7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 563abba82ed5b7e1308edcc406dfc842b4f7bfa1c7ae489d886f73a67737261690a8faeec48d0ff7e2458f57ec3142620da88777625362e4b2c874f8bd93873d
|
|
7
|
+
data.tar.gz: bb6450674d14ba9aa4b9e9b35c54ebc740c55903ae0f3b19ead162c6764948c37bb69029e903ad75886dc7de40f1628e81c0af5ba411a7a015ff600d91bdaf7a
|
data/README.md
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
This gem supports the following versions of Ruby and Rails:
|
|
13
13
|
|
|
14
|
-
* Ruby
|
|
14
|
+
* Ruby 3.1.0+
|
|
15
15
|
* Rails 6.0.0+
|
|
16
16
|
|
|
17
17
|
## Usage
|
|
@@ -123,6 +123,124 @@ This gem supports the following versions of Ruby and Rails:
|
|
|
123
123
|
end
|
|
124
124
|
```
|
|
125
125
|
|
|
126
|
+
## Brotli Splice Slots
|
|
127
|
+
|
|
128
|
+
Applications that need per-request replacement inside cached Brotli HTML responses can pass an injector builder to `ResponseBank::Middleware`:
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
use ResponseBank::Middleware, ->(env) { HtmlMetadataInjector.new(env) }
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Rails applications can configure the same builder through `config.response_bank`:
|
|
135
|
+
|
|
136
|
+
```ruby
|
|
137
|
+
config.response_bank.brotli_splice_injector =
|
|
138
|
+
->(env) { HtmlMetadataInjector.new(env) }
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
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.
|
|
142
|
+
|
|
143
|
+
Injectors may include `ResponseBank::BrotliSpliceInjector` to document the required methods:
|
|
144
|
+
|
|
145
|
+
```ruby
|
|
146
|
+
class HtmlMetadataInjector
|
|
147
|
+
include ResponseBank::BrotliSpliceInjector
|
|
148
|
+
|
|
149
|
+
# The per-request value spliced in on cache hits (a 36-byte UUID).
|
|
150
|
+
TOKEN_PLACEHOLDER = "00000000-0000-0000-0000-000000000000"
|
|
151
|
+
# BrotliSplice reserves the LAST 2 bytes of a slot as a fixed "\r\n" context
|
|
152
|
+
# suffix, so a slot must span 2 more bytes than its replaceable region and
|
|
153
|
+
# `replacement_length` always comes back as `slot length - 2`. We let those
|
|
154
|
+
# 2 bytes be a real "\r\n" placed after the tag, where a line break is
|
|
155
|
+
# harmless — the replaceable region is therefore `<uuid>">` (38 bytes).
|
|
156
|
+
CONTEXT_SUFFIX = "\r\n"
|
|
157
|
+
PLACEHOLDER_TAG = %(<meta name="shopify-y" content="#{TOKEN_PLACEHOLDER}">#{CONTEXT_SUFFIX})
|
|
158
|
+
SLOT = %(#{TOKEN_PLACEHOLDER}">#{CONTEXT_SUFFIX}) # 38 replaceable bytes + 2 reserved
|
|
159
|
+
|
|
160
|
+
def initialize(env)
|
|
161
|
+
@env = env
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def prepare_response_bank_brotli_splice(body, _headers)
|
|
165
|
+
body_with_placeholder = body.sub("</head>", "#{PLACEHOLDER_TAG}</head>")
|
|
166
|
+
# Use a byte offset: BrotliSplice.encode addresses the slot by bytes.
|
|
167
|
+
offset = body_with_placeholder.b.index(TOKEN_PLACEHOLDER)
|
|
168
|
+
return unless offset
|
|
169
|
+
|
|
170
|
+
{
|
|
171
|
+
body: body_with_placeholder,
|
|
172
|
+
slots: [
|
|
173
|
+
{
|
|
174
|
+
name: "shopify_y",
|
|
175
|
+
offset: offset,
|
|
176
|
+
# Include the 2 bytes reserved for the context suffix, or the
|
|
177
|
+
# replacement below will be 2 bytes too long and silently dropped.
|
|
178
|
+
length: SLOT.bytesize,
|
|
179
|
+
},
|
|
180
|
+
],
|
|
181
|
+
}
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def response_bank_brotli_splice_replacement(slot)
|
|
185
|
+
# Must return EXACTLY slot["replacement_length"] bytes (== slot length - 2).
|
|
186
|
+
# If it does not, ResponseBank skips the splice and serves the neutral
|
|
187
|
+
# placeholder, so guard the length rather than assuming it.
|
|
188
|
+
replacement = %(#{shopify_y}">)
|
|
189
|
+
return unless replacement.bytesize == slot.fetch("replacement_length")
|
|
190
|
+
|
|
191
|
+
replacement
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def replace_response_bank_brotli_splice_placeholders(body, slots)
|
|
195
|
+
slots.reduce(body) do |current, slot|
|
|
196
|
+
replacement = response_bank_brotli_splice_replacement(slot)
|
|
197
|
+
next current unless replacement
|
|
198
|
+
|
|
199
|
+
offset = slot.fetch("html_placeholder_offset")
|
|
200
|
+
length = slot.fetch("html_placeholder_length")
|
|
201
|
+
suffix = slot.fetch("context_suffix", CONTEXT_SUFFIX)
|
|
202
|
+
|
|
203
|
+
# replacement + suffix must equal the original slot length (byteslice
|
|
204
|
+
# replaces `length` bytes), keeping the body byte-for-byte consistent.
|
|
205
|
+
current.byteslice(0, offset) + replacement + suffix +
|
|
206
|
+
current.byteslice(offset + length, current.bytesize)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
private
|
|
211
|
+
|
|
212
|
+
def shopify_y
|
|
213
|
+
@env.fetch("HTTP_SHOPIFY_Y") # 36-byte UUID
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
`prepare_response_bank_brotli_splice` is used on cache writes. It returns HTML
|
|
219
|
+
containing a neutral placeholder and one slot describing that placeholder.
|
|
220
|
+
ResponseBank stores the slot metadata with the cached Brotli body. The slot's
|
|
221
|
+
`offset` and `length` are **byte** offsets/counts — `BrotliSplice.encode`
|
|
222
|
+
addresses the stream by bytes — so locate the placeholder on a binary view
|
|
223
|
+
(`body.b.index(...)`) and size it with `bytesize`. A plain `String#index`/`size`
|
|
224
|
+
silently breaks once any multi-byte UTF-8 precedes the placeholder: a character
|
|
225
|
+
offset is always ≤ the byte length, so the bounds check still passes.
|
|
226
|
+
|
|
227
|
+
`response_bank_brotli_splice_replacement` is used on Brotli cache hits. It must
|
|
228
|
+
return exactly `slot["replacement_length"]` bytes. Note that `replacement_length`
|
|
229
|
+
is **2 fewer** than the `length` you registered in the slot: `BrotliSplice.encode`
|
|
230
|
+
reserves the last 2 bytes of every slot as a fixed `\r\n` context suffix. So size
|
|
231
|
+
your placeholder to include those 2 bytes (as the example does with the trailing
|
|
232
|
+
`\r\n`), and have the replacement match `replacement_length`. If the byte length
|
|
233
|
+
does not match, ResponseBank **silently skips the splice and serves the neutral
|
|
234
|
+
placeholder** — no exception is raised — so guard the length instead of assuming it.
|
|
235
|
+
|
|
236
|
+
`replace_response_bank_brotli_splice_placeholders` is used when a cached Brotli response is decompressed for a client that does not accept Brotli.
|
|
237
|
+
|
|
238
|
+
Advanced integrations can still install the per-request injector directly in the Rack env before ResponseBank reads or writes the cached body:
|
|
239
|
+
|
|
240
|
+
```ruby
|
|
241
|
+
env[ResponseBank::BrotliSpliceSlot::INJECTOR_ENV_KEY] = injector
|
|
242
|
+
```
|
|
243
|
+
|
|
126
244
|
## License
|
|
127
245
|
|
|
128
246
|
ResponseBank is released under the [MIT License](LICENSE.txt).
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ResponseBank
|
|
4
|
+
module BrotliSpliceInjector
|
|
5
|
+
def prepare_response_bank_brotli_splice(body, headers)
|
|
6
|
+
raise NotImplementedError
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def response_bank_brotli_splice_replacement(slot)
|
|
10
|
+
raise NotImplementedError
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def replace_response_bank_brotli_splice_placeholders(body, slots)
|
|
14
|
+
raise NotImplementedError
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ResponseBank
|
|
4
|
+
module BrotliSpliceSlot
|
|
5
|
+
INJECTOR_ENV_KEY = 'response_bank.html_metadata_injector'
|
|
6
|
+
METADATA_KEY = 'brotli_splice'
|
|
7
|
+
METADATA_VERSION = 1
|
|
8
|
+
# BrotliSplice reserves the last 2 bytes of every slot as a fixed "\r\n" context
|
|
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.
|
|
11
|
+
CONTEXT_SUFFIX_LENGTH = 2
|
|
12
|
+
|
|
13
|
+
EncodedBody = Struct.new(:body, :compressed_body, :metadata, keyword_init: true)
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
def encode_body(env, body, headers, compression_level:)
|
|
17
|
+
injector = env[INJECTOR_ENV_KEY]
|
|
18
|
+
return unless injector
|
|
19
|
+
return unless body && body != ''
|
|
20
|
+
|
|
21
|
+
prepared = injector.prepare_response_bank_brotli_splice(body, headers)
|
|
22
|
+
return unless prepared
|
|
23
|
+
|
|
24
|
+
prepared_body = hash_fetch(prepared, :body)
|
|
25
|
+
slots = hash_fetch(prepared, :slots)
|
|
26
|
+
return unless prepared_body && slots && slots.length == 1
|
|
27
|
+
|
|
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)
|
|
33
|
+
|
|
34
|
+
# Load the native gem only once we have real splice work to do. Keeping this
|
|
35
|
+
# outside the begin/rescue below is deliberate: the rescue clause references
|
|
36
|
+
# BrotliSplice::Error, so if the gem is missing, evaluating that clause would
|
|
37
|
+
# raise NameError and mask the LoadError we want the caller to see.
|
|
38
|
+
ensure_brotli_splice_loaded!
|
|
39
|
+
|
|
40
|
+
begin
|
|
41
|
+
result = BrotliSplice.encode(prepared_body, html_offset, html_length, quality: compression_level)
|
|
42
|
+
|
|
43
|
+
metadata_slot = {
|
|
44
|
+
'name' => slot_name,
|
|
45
|
+
'compressed_offset' => result[:secret_offset],
|
|
46
|
+
'replacement_length' => result[:secret_length],
|
|
47
|
+
'html_placeholder_offset' => html_offset,
|
|
48
|
+
'html_placeholder_length' => html_length,
|
|
49
|
+
}
|
|
50
|
+
metadata_slot['context_suffix'] = result[:context_suffix] if result[:context_suffix]
|
|
51
|
+
|
|
52
|
+
EncodedBody.new(
|
|
53
|
+
body: prepared_body,
|
|
54
|
+
compressed_body: result[:data],
|
|
55
|
+
metadata: {
|
|
56
|
+
METADATA_KEY => {
|
|
57
|
+
'version' => METADATA_VERSION,
|
|
58
|
+
'slots' => [metadata_slot],
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
)
|
|
62
|
+
rescue BrotliSplice::Error, ArgumentError => error
|
|
63
|
+
ResponseBank.log("BrotliSplice encode skipped: #{error.class}")
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def replace_compressed_secret(env, body, metadata)
|
|
69
|
+
injector = env[INJECTOR_ENV_KEY]
|
|
70
|
+
slots = metadata_slots(metadata)
|
|
71
|
+
return body unless injector && slots && body && body != ''
|
|
72
|
+
|
|
73
|
+
# See encode_body: load outside the begin/rescue so a missing gem surfaces as
|
|
74
|
+
# LoadError rather than a masked NameError from the BrotliSplice::Error clause.
|
|
75
|
+
ensure_brotli_splice_loaded!
|
|
76
|
+
|
|
77
|
+
begin
|
|
78
|
+
slots.reduce(body) do |current_body, slot|
|
|
79
|
+
replacement = replacement_for_slot(injector, slot)
|
|
80
|
+
|
|
81
|
+
unless valid_replacement?(replacement, slot)
|
|
82
|
+
got = replacement ? replacement.bytesize : 'nil'
|
|
83
|
+
ResponseBank.log(
|
|
84
|
+
'BrotliSplice replace skipped: replacement length mismatch ' \
|
|
85
|
+
"(got #{got}, want #{slot['replacement_length']})",
|
|
86
|
+
)
|
|
87
|
+
next current_body
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
offset = integer_value(slot['compressed_offset'])
|
|
91
|
+
length = integer_value(slot['replacement_length'])
|
|
92
|
+
|
|
93
|
+
unless valid_compressed_slot?(current_body, offset, length)
|
|
94
|
+
ResponseBank.log('BrotliSplice replace skipped: compressed slot out of bounds')
|
|
95
|
+
next current_body
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
BrotliSplice.replace(current_body, replacement, offset, length)
|
|
99
|
+
end
|
|
100
|
+
rescue BrotliSplice::Error, ArgumentError => error
|
|
101
|
+
ResponseBank.log("BrotliSplice replace skipped: #{error.class}")
|
|
102
|
+
body
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def replace_plain_body(env, body, metadata)
|
|
107
|
+
injector = env[INJECTOR_ENV_KEY]
|
|
108
|
+
slots = metadata_slots(metadata)
|
|
109
|
+
return body unless injector && slots && body && body != ''
|
|
110
|
+
|
|
111
|
+
injector.replace_response_bank_brotli_splice_placeholders(body, slots)
|
|
112
|
+
rescue ArgumentError => error
|
|
113
|
+
ResponseBank.log("BrotliSplice plain replacement skipped: #{error.class}")
|
|
114
|
+
body
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def metadata_slots(metadata)
|
|
118
|
+
return unless metadata
|
|
119
|
+
|
|
120
|
+
brotli_splice = metadata[METADATA_KEY]
|
|
121
|
+
return unless brotli_splice && brotli_splice['version'] == METADATA_VERSION
|
|
122
|
+
|
|
123
|
+
brotli_splice['slots']
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
# Load the native brotli_splice gem on first use. It is an optional dependency:
|
|
129
|
+
# apps opt into Brotli splice slots by installing an injector, and only those
|
|
130
|
+
# apps need the gem. If it is missing when we actually need it, fail loudly with
|
|
131
|
+
# a pointer to the fix rather than limping along.
|
|
132
|
+
def ensure_brotli_splice_loaded!
|
|
133
|
+
return if @brotli_splice_loaded
|
|
134
|
+
|
|
135
|
+
begin
|
|
136
|
+
gem('brotli_splice')
|
|
137
|
+
require('brotli_splice')
|
|
138
|
+
rescue LoadError => error
|
|
139
|
+
warn(
|
|
140
|
+
'The Brotli splice slot feature requires the "brotli_splice" gem. ' \
|
|
141
|
+
'Add it to your application Gemfile.',
|
|
142
|
+
)
|
|
143
|
+
raise error
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
@brotli_splice_loaded = true
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def replacement_for_slot(injector, slot)
|
|
150
|
+
injector.response_bank_brotli_splice_replacement(slot)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def valid_replacement?(replacement, slot)
|
|
154
|
+
return false unless replacement
|
|
155
|
+
|
|
156
|
+
replacement.bytesize == slot['replacement_length']
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def valid_compressed_slot?(body, offset, length)
|
|
160
|
+
return false unless offset && length
|
|
161
|
+
return false unless offset >= 0 && length > 0
|
|
162
|
+
|
|
163
|
+
offset + length <= body.bytesize
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def valid_html_slot?(body, offset, length)
|
|
167
|
+
return false unless offset && length
|
|
168
|
+
return false unless offset >= 0 && length > CONTEXT_SUFFIX_LENGTH
|
|
169
|
+
|
|
170
|
+
offset + length <= body.bytesize
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def integer_value(value)
|
|
174
|
+
Integer(value)
|
|
175
|
+
rescue ArgumentError, TypeError
|
|
176
|
+
nil
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def hash_fetch(hash, key)
|
|
180
|
+
hash[key] || hash[key.to_s]
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
require 'response_bank/brotli_splice_slot'
|
|
2
3
|
|
|
3
4
|
module ResponseBank
|
|
4
5
|
class Middleware
|
|
@@ -10,12 +11,15 @@ module ResponseBank
|
|
|
10
11
|
ACCEPT = "HTTP_ACCEPT"
|
|
11
12
|
USER_AGENT = "HTTP_USER_AGENT"
|
|
12
13
|
|
|
13
|
-
def initialize(app)
|
|
14
|
+
def initialize(app, brotli_splice_injector = nil)
|
|
14
15
|
@app = app
|
|
16
|
+
@brotli_splice_injector = brotli_splice_injector
|
|
15
17
|
end
|
|
16
18
|
|
|
17
19
|
def call(env)
|
|
18
20
|
env['cacheable.cache'] = false
|
|
21
|
+
install_brotli_splice_injector(env)
|
|
22
|
+
|
|
19
23
|
content_encoding = env['response_bank.server_cache_encoding'] = ResponseBank.check_encoding(env)
|
|
20
24
|
|
|
21
25
|
status, headers, body = @app.call(env)
|
|
@@ -35,14 +39,40 @@ module ResponseBank
|
|
|
35
39
|
end
|
|
36
40
|
|
|
37
41
|
body_compressed = nil
|
|
42
|
+
metadata = nil
|
|
38
43
|
if body_string && body_string != ""
|
|
39
44
|
headers['Content-Encoding'] = content_encoding
|
|
40
|
-
|
|
45
|
+
env["cacheable.compression_level"] = ResponseBank.compression_level_for_request(env, headers)
|
|
46
|
+
time = ResponseBank.measure do
|
|
47
|
+
encoded_body = if content_encoding == 'br'
|
|
48
|
+
ResponseBank::BrotliSpliceSlot.encode_body(
|
|
49
|
+
env,
|
|
50
|
+
body_string,
|
|
51
|
+
headers,
|
|
52
|
+
compression_level: env["cacheable.compression_level"],
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
if encoded_body
|
|
57
|
+
body_string = encoded_body.body
|
|
58
|
+
body_compressed = encoded_body.compressed_body
|
|
59
|
+
metadata = encoded_body.metadata
|
|
60
|
+
else
|
|
61
|
+
body_compressed = ResponseBank.compress(
|
|
62
|
+
body_string,
|
|
63
|
+
content_encoding,
|
|
64
|
+
compression_level: env["cacheable.compression_level"],
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
ResponseBank.log("Compression time: #{time}ms")
|
|
69
|
+
env["cacheable.compression_time"] = time
|
|
41
70
|
end
|
|
42
71
|
|
|
43
72
|
cached_headers = headers.slice(*CACHEABLE_HEADERS)
|
|
44
73
|
# Store result
|
|
45
|
-
cache_data = [status, cached_headers, body_compressed, timestamp]
|
|
74
|
+
cache_data = [status, cached_headers, body_compressed, timestamp, env["cacheable.compression_level"]]
|
|
75
|
+
cache_data << metadata if metadata
|
|
46
76
|
|
|
47
77
|
ResponseBank.write_to_cache(env['cacheable.key']) do
|
|
48
78
|
payload = MessagePack.dump(cache_data)
|
|
@@ -58,10 +88,15 @@ module ResponseBank
|
|
|
58
88
|
# as well serve it if the client wants it
|
|
59
89
|
if body_compressed
|
|
60
90
|
if env['HTTP_ACCEPT_ENCODING'].to_s.include?(content_encoding)
|
|
61
|
-
|
|
91
|
+
if content_encoding == 'br'
|
|
92
|
+
body = [ResponseBank::BrotliSpliceSlot.replace_compressed_secret(env, body_compressed, metadata)]
|
|
93
|
+
else
|
|
94
|
+
body = [body_compressed]
|
|
95
|
+
end
|
|
62
96
|
else
|
|
63
97
|
# Remove content-encoding header for response with compressed content
|
|
64
98
|
headers.delete('Content-Encoding')
|
|
99
|
+
body = [ResponseBank::BrotliSpliceSlot.replace_plain_body(env, body_string, metadata)] if metadata
|
|
65
100
|
end
|
|
66
101
|
end
|
|
67
102
|
end
|
|
@@ -78,6 +113,19 @@ module ResponseBank
|
|
|
78
113
|
|
|
79
114
|
private
|
|
80
115
|
|
|
116
|
+
def install_brotli_splice_injector(env)
|
|
117
|
+
return unless @brotli_splice_injector
|
|
118
|
+
return if env.key?(ResponseBank::BrotliSpliceSlot::INJECTOR_ENV_KEY)
|
|
119
|
+
|
|
120
|
+
injector = if @brotli_splice_injector.respond_to?(:call)
|
|
121
|
+
@brotli_splice_injector.call(env)
|
|
122
|
+
else
|
|
123
|
+
@brotli_splice_injector
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
env[ResponseBank::BrotliSpliceSlot::INJECTOR_ENV_KEY] = injector if injector
|
|
127
|
+
end
|
|
128
|
+
|
|
81
129
|
def timestamp
|
|
82
130
|
Time.now.to_i
|
|
83
131
|
end
|
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
require 'rails'
|
|
3
|
+
require 'active_support/ordered_options'
|
|
3
4
|
require 'response_bank/controller'
|
|
4
5
|
require 'response_bank/model_extensions'
|
|
5
6
|
|
|
6
7
|
module ResponseBank
|
|
7
8
|
class Railtie < ::Rails::Railtie
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
config.response_bank = ActiveSupport::OrderedOptions.new
|
|
10
|
+
config.response_bank.brotli_splice_injector = nil
|
|
11
|
+
|
|
12
|
+
initializer "cachable.configure_active_record" do |app|
|
|
13
|
+
app.config.middleware.insert_after(
|
|
14
|
+
Rack::Head,
|
|
15
|
+
ResponseBank::Middleware,
|
|
16
|
+
app.config.response_bank.brotli_splice_injector,
|
|
17
|
+
)
|
|
10
18
|
|
|
11
19
|
ActiveSupport.on_load(:action_controller) do
|
|
12
20
|
include ResponseBank::Controller
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
require 'digest/md5'
|
|
3
|
+
require 'response_bank/brotli_splice_slot'
|
|
3
4
|
|
|
4
5
|
module ResponseBank
|
|
5
6
|
class ResponseCacheHandler
|
|
@@ -119,7 +120,9 @@ module ResponseBank
|
|
|
119
120
|
@env['cacheable.miss'] = false
|
|
120
121
|
@env['cacheable.store'] = 'server'
|
|
121
122
|
|
|
122
|
-
status, headers, body, timestamp = hit
|
|
123
|
+
status, headers, body, timestamp, compression_level, metadata = hit
|
|
124
|
+
|
|
125
|
+
@env['cacheable.compression_level'] = compression_level
|
|
123
126
|
|
|
124
127
|
@env['cacheable.locked'] ||= false
|
|
125
128
|
|
|
@@ -150,9 +153,14 @@ module ResponseBank
|
|
|
150
153
|
@headers.merge!(headers)
|
|
151
154
|
|
|
152
155
|
if @headers['Content-Encoding']
|
|
153
|
-
if
|
|
156
|
+
if @env['HTTP_ACCEPT_ENCODING'].to_s.include?(@headers['Content-Encoding'])
|
|
157
|
+
if @headers['Content-Encoding'] == 'br'
|
|
158
|
+
body = ResponseBank::BrotliSpliceSlot.replace_compressed_secret(@env, body, metadata)
|
|
159
|
+
end
|
|
160
|
+
else
|
|
154
161
|
ResponseBank.log("uncompressing payload for client as client doesn't require encoding")
|
|
155
162
|
body = ResponseBank.decompress(body, @headers['Content-Encoding'])
|
|
163
|
+
body = ResponseBank::BrotliSpliceSlot.replace_plain_body(@env, body, metadata)
|
|
156
164
|
@headers.delete('Content-Encoding')
|
|
157
165
|
end
|
|
158
166
|
else
|
|
@@ -178,9 +186,9 @@ module ResponseBank
|
|
|
178
186
|
|
|
179
187
|
# strictly speaking an unquoted etag is not valid, yet common
|
|
180
188
|
# to avoid unintended greedy matches in we check for naked entity then includes with quoted entity values
|
|
181
|
-
entity_tag = %{"#{entity_tag}"} unless entity_tag.
|
|
189
|
+
entity_tag = %{"#{entity_tag}"} unless entity_tag.start_with?('"')
|
|
182
190
|
|
|
183
|
-
if_none_match = %{"#{if_none_match}"} unless if_none_match.
|
|
191
|
+
if_none_match = %{"#{if_none_match}"} unless if_none_match.start_with?('"') || if_none_match.start_with?('W/"')
|
|
184
192
|
|
|
185
193
|
if_none_match == entity_tag || if_none_match.include?(entity_tag)
|
|
186
194
|
end
|
data/lib/response_bank.rb
CHANGED
|
@@ -1,14 +1,36 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
require 'response_bank/brotli_splice_injector'
|
|
3
|
+
require 'response_bank/brotli_splice_slot'
|
|
2
4
|
require 'response_bank/middleware'
|
|
3
5
|
require 'response_bank/railtie' if defined?(Rails)
|
|
4
6
|
require 'response_bank/response_cache_handler'
|
|
5
7
|
require 'msgpack'
|
|
6
8
|
require 'brotli'
|
|
9
|
+
require 'benchmark'
|
|
7
10
|
|
|
8
11
|
module ResponseBank
|
|
9
12
|
class << self
|
|
10
13
|
attr_accessor :cache_store
|
|
11
|
-
attr_writer :logger
|
|
14
|
+
attr_writer :logger, :compression_level
|
|
15
|
+
|
|
16
|
+
DEFAULT_BROTLI_COMPRESSION_LEVEL = 7
|
|
17
|
+
|
|
18
|
+
DEFAULT_COMPRESSION_LEVEL = -> (_env, headers) {
|
|
19
|
+
case headers['Content-Encoding']
|
|
20
|
+
when 'br'
|
|
21
|
+
DEFAULT_BROTLI_COMPRESSION_LEVEL
|
|
22
|
+
when 'gzip'
|
|
23
|
+
Zlib::BEST_COMPRESSION
|
|
24
|
+
end
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
def compression_level_for_request(env, headers)
|
|
28
|
+
if @compression_level
|
|
29
|
+
return @compression_level.respond_to?(:call) ? @compression_level.call(env, headers) : @compression_level
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
DEFAULT_COMPRESSION_LEVEL.call(env, headers)
|
|
33
|
+
end
|
|
12
34
|
|
|
13
35
|
def log(message)
|
|
14
36
|
@logger.info("[ResponseBank] #{message}")
|
|
@@ -30,13 +52,19 @@ module ResponseBank
|
|
|
30
52
|
backing_cache_store.read(cache_key, raw: true)
|
|
31
53
|
end
|
|
32
54
|
|
|
33
|
-
def
|
|
55
|
+
def measure
|
|
56
|
+
Benchmark.realtime do
|
|
57
|
+
yield
|
|
58
|
+
end * 1000 # milliseconds
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def compress(content, encoding = "br", compression_level: nil)
|
|
34
62
|
case encoding
|
|
35
63
|
when 'gzip'
|
|
36
64
|
attempts = 0
|
|
37
65
|
|
|
38
66
|
begin
|
|
39
|
-
Zlib.gzip(content, level: Zlib::BEST_COMPRESSION)
|
|
67
|
+
Zlib.gzip(content, level: compression_level || Zlib::BEST_COMPRESSION)
|
|
40
68
|
rescue Zlib::BufError
|
|
41
69
|
# We get sporadic Zlib::BufError, so we retry once (https://github.com/ruby/zlib/issues/49)
|
|
42
70
|
attempts += 1
|
|
@@ -48,7 +76,7 @@ module ResponseBank
|
|
|
48
76
|
end
|
|
49
77
|
end
|
|
50
78
|
when 'br'
|
|
51
|
-
Brotli.deflate(content, mode: :text, quality:
|
|
79
|
+
Brotli.deflate(content, mode: :text, quality: compression_level || DEFAULT_BROTLI_COMPRESSION_LEVEL)
|
|
52
80
|
else
|
|
53
81
|
raise ArgumentError, "Unsupported encoding: #{encoding}"
|
|
54
82
|
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.3.
|
|
4
|
+
version: 1.3.8
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Tobias Lütke
|
|
@@ -38,6 +38,20 @@ dependencies:
|
|
|
38
38
|
- - ">="
|
|
39
39
|
- !ruby/object:Gem::Version
|
|
40
40
|
version: '0'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: brotli_splice
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - '='
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: 0.1.1
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - '='
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: 0.1.1
|
|
41
55
|
- !ruby/object:Gem::Dependency
|
|
42
56
|
name: minitest
|
|
43
57
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -118,6 +132,8 @@ files:
|
|
|
118
132
|
- LICENSE.txt
|
|
119
133
|
- README.md
|
|
120
134
|
- lib/response_bank.rb
|
|
135
|
+
- lib/response_bank/brotli_splice_injector.rb
|
|
136
|
+
- lib/response_bank/brotli_splice_slot.rb
|
|
121
137
|
- lib/response_bank/controller.rb
|
|
122
138
|
- lib/response_bank/middleware.rb
|
|
123
139
|
- lib/response_bank/model_extensions.rb
|
|
@@ -136,14 +152,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
136
152
|
requirements:
|
|
137
153
|
- - ">="
|
|
138
154
|
- !ruby/object:Gem::Version
|
|
139
|
-
version:
|
|
155
|
+
version: 3.1.0
|
|
140
156
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
141
157
|
requirements:
|
|
142
158
|
- - ">="
|
|
143
159
|
- !ruby/object:Gem::Version
|
|
144
160
|
version: '0'
|
|
145
161
|
requirements: []
|
|
146
|
-
rubygems_version:
|
|
162
|
+
rubygems_version: 4.0.14
|
|
147
163
|
specification_version: 4
|
|
148
164
|
summary: Simple response caching for Ruby applications
|
|
149
165
|
test_files: []
|