findxpand 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/README.md +325 -0
- data/exe/findxpand +20 -0
- data/findxpand.gemspec +71 -0
- data/lib/findxpand/admin.rb +157 -0
- data/lib/findxpand/auto.rb +607 -0
- data/lib/findxpand/cli.rb +214 -0
- data/lib/findxpand/encoding.rb +160 -0
- data/lib/findxpand/middleware.rb +597 -0
- data/lib/findxpand/railtie.rb +72 -0
- data/lib/findxpand/rewrite.rb +628 -0
- data/lib/findxpand/signature.rb +127 -0
- data/lib/findxpand/store.rb +657 -0
- data/lib/findxpand.rb +255 -0
- metadata +62 -0
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The Rack middleware — Rails, Sinatra, Hanami, Roda, or a bare `->(env){}`.
|
|
4
|
+
#
|
|
5
|
+
# use Findxpand::Middleware
|
|
6
|
+
#
|
|
7
|
+
# One deploy. After that, approved fixes arrive as a pushed manifest and no
|
|
8
|
+
# further release is needed — which is the reason this exists rather than a JSON
|
|
9
|
+
# file in the repository.
|
|
10
|
+
#
|
|
11
|
+
# **Prefer `findxpand/auto`. Do not mount this by hand.** `use` is exported and
|
|
12
|
+
# correct, and it still asks somebody to place a middleware correctly in a stack
|
|
13
|
+
# we cannot see. `auto.rb` removes the question by wrapping whatever
|
|
14
|
+
# builder the server built the application with - its own vendored one
|
|
15
|
+
# included, which is what Puma has - and by owning the encoding negotiation so
|
|
16
|
+
# that being outermost is finally the right place to be. See `encoding.rb` for
|
|
17
|
+
# the measurement that killed the old instruction, and `auto.rb` for why one
|
|
18
|
+
# class name was not enough.
|
|
19
|
+
#
|
|
20
|
+
# **It never fails a request it does not understand.** Every branch either
|
|
21
|
+
# handles the response or passes it through untouched, and the rewrite itself is
|
|
22
|
+
# wrapped so that an exception serves the original bytes. Being able to 500 a
|
|
23
|
+
# client's homepage in exchange for a meta description is not a trade anyone
|
|
24
|
+
# agreed to. §3 rule 7 fails writes, credentials and budgets closed — it does not
|
|
25
|
+
# ask us to stop a page rendering.
|
|
26
|
+
#
|
|
27
|
+
# ## Two things this does to the request, and only on a path with a rule
|
|
28
|
+
#
|
|
29
|
+
# 1. `Accept-Encoding` becomes `identity`, so nothing below us gzips a body we
|
|
30
|
+
# are about to rewrite. `encoding.rb` has the measurement.
|
|
31
|
+
# 2. `If-None-Match` and `If-Modified-Since` are dropped, so nothing below us
|
|
32
|
+
# answers `304 Not Modified` for a page whose bytes we are changing. Without
|
|
33
|
+
# it a returning visitor — and a crawler with our page already in its cache —
|
|
34
|
+
# is served the *old* document from its own cache indefinitely, while
|
|
35
|
+
# `/status` counts the fix as applied for everybody else. That is a fix
|
|
36
|
+
# verified as deployed and not delivered, which §3 rule 4 says must never
|
|
37
|
+
# read as success. **This has no counterpart in the Node or Python packages
|
|
38
|
+
# and should be promoted into all three.**
|
|
39
|
+
#
|
|
40
|
+
# Every other path keeps its own headers exactly as they arrived, which is what
|
|
41
|
+
# keeps the cost of turning this on at zero.
|
|
42
|
+
#
|
|
43
|
+
# ## Every visitor gets identical bytes
|
|
44
|
+
#
|
|
45
|
+
# There is no user-agent branch anywhere in this gem. Serving crawlers something
|
|
46
|
+
# different is cloaking, and the whole point of a server-side rewrite is that it
|
|
47
|
+
# needs no such trick (§12).
|
|
48
|
+
#
|
|
49
|
+
# Frozen string literals: on. Buffered chunks are joined, never appended to a
|
|
50
|
+
# literal, and the header hash is duplicated before it is written to because a
|
|
51
|
+
# Rack application is entitled to return a frozen one.
|
|
52
|
+
|
|
53
|
+
require 'json'
|
|
54
|
+
|
|
55
|
+
module Findxpand
|
|
56
|
+
class Middleware
|
|
57
|
+
# Set on every HTML response this middleware handled. Deliberately not the
|
|
58
|
+
# edge worker's `x-findxpand-edge`: a site can run both, and a verification
|
|
59
|
+
# that could not tell which layer served the value would report the wrong
|
|
60
|
+
# thing as deployed. Mirrors `engine.fix.origin.ORIGIN_HEADER`.
|
|
61
|
+
ORIGIN_HEADER = 'x-findxpand-origin-mw'
|
|
62
|
+
VERSION_HEADER = 'x-findxpand-rules'
|
|
63
|
+
|
|
64
|
+
DEFAULT_MAX_BYTES = 2 * 1024 * 1024
|
|
65
|
+
|
|
66
|
+
# Mirrors `engine.fix.origin.STATUS_CODES`. Nothing 2xx or 3xx: a move is a
|
|
67
|
+
# redirect, and forcing 200 would mean inventing a body.
|
|
68
|
+
ALLOWED_STATUS = [404, 410, 451].freeze
|
|
69
|
+
STATUS_BODY = { 404 => 'Not Found', 410 => 'Gone', 451 => 'Unavailable' }.freeze
|
|
70
|
+
|
|
71
|
+
# Marks the request as already ours, so a second instance in the same stack
|
|
72
|
+
# passes straight through.
|
|
73
|
+
#
|
|
74
|
+
# Ruby has three attach points and two of them can be live at once — the
|
|
75
|
+
# builder prepend wraps the app that `config.ru` builds, and the Railtie
|
|
76
|
+
# inserts at position 0 of the Rails stack *inside* that. Nesting is
|
|
77
|
+
# therefore the normal case rather than a misconfiguration, and two live
|
|
78
|
+
# instances would rewrite an already-rewritten page and gzip an
|
|
79
|
+
# already-gzipped one. Marked on the request rather than on the app object
|
|
80
|
+
# because the app object may be frozen, may be a lambda, and may be built
|
|
81
|
+
# lazily on the first request (Sinatra) — the env is the one thing every
|
|
82
|
+
# instance in a stack is guaranteed to share.
|
|
83
|
+
SEEN_KEY = 'findxpand.seen'
|
|
84
|
+
|
|
85
|
+
# Dropped from the request on a ruled path. See the header.
|
|
86
|
+
CONDITIONAL_ENV = %w[HTTP_IF_NONE_MATCH HTTP_IF_MODIFIED_SINCE].freeze
|
|
87
|
+
|
|
88
|
+
# The four statuses a manifest redirect may carry, matching
|
|
89
|
+
# `middleware/php/src/Store.php:391`. Anything else is served as a 301
|
|
90
|
+
# rather than passed through: a 418 with a `location` is not a redirect any
|
|
91
|
+
# client follows, and a manifest that reached us with one is a manifest we
|
|
92
|
+
# do not trust to have meant it.
|
|
93
|
+
REDIRECT_STATUS = [301, 302, 307, 308].freeze
|
|
94
|
+
|
|
95
|
+
attr_reader :store
|
|
96
|
+
|
|
97
|
+
# How many of these have been built in this process, ever.
|
|
98
|
+
#
|
|
99
|
+
# **The only evidence that the attach reached anything.** `Auto.install`
|
|
100
|
+
# returning true says a hook was placed; it does not say the hook ran, and
|
|
101
|
+
# until 2 Sep 2026 it did not run on Puma at all — Puma parses `config.ru`
|
|
102
|
+
# with its own vendored builder, and the prepend was on `Rack::Builder`
|
|
103
|
+
# alone (see `auto.rb`, which now attaches by shape as well as by name). A
|
|
104
|
+
# count kept here, in the object that is the whole point of attaching, is
|
|
105
|
+
# the one fact that cannot be true while the attach is a no-op. Read by
|
|
106
|
+
# `Findxpand.attach_report` for `/status` and by `Auto.attach_problem` for
|
|
107
|
+
# the sentence that names which half failed, so neither of them has to infer
|
|
108
|
+
# it from a hook it placed (§20.1 rule 3: report what was observed, not what
|
|
109
|
+
# was arranged).
|
|
110
|
+
@constructed = 0
|
|
111
|
+
@constructed_lock = Mutex.new
|
|
112
|
+
|
|
113
|
+
class << self
|
|
114
|
+
attr_reader :constructed
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def self.count_construction
|
|
118
|
+
@constructed_lock.synchronize { @constructed += 1 }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# For the suite only.
|
|
122
|
+
def self.reset_constructed!
|
|
123
|
+
@constructed_lock.synchronize { @constructed = 0 }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Options resolve **explicit keyword, then environment, then default** — on
|
|
127
|
+
# every construction path, which is the whole of this change.
|
|
128
|
+
#
|
|
129
|
+
# `use Findxpand::Middleware` with no arguments is advertised as a complete
|
|
130
|
+
# install and the Railtie's `insert_before 0, Findxpand::Middleware` gives
|
|
131
|
+
# Rails no way to pass one. Until 2 Sep 2026 only `token` fell back to the
|
|
132
|
+
# environment here; `cache_file`, `manifest_file`, `max_bytes`,
|
|
133
|
+
# `require_signature`, `recompress` and `enabled` did not, and
|
|
134
|
+
# `Findxpand.options_from_env` had exactly one caller in the whole gem
|
|
135
|
+
# (`auto.rb`). So every install that was not the `RUBYOPT` attach — which on
|
|
136
|
+
# Rails is *every* install, because the Railtie runs first — built a store
|
|
137
|
+
# with `cache_file: nil` while `FINDXPAND_CACHE_FILE` was set in the
|
|
138
|
+
# environment beside it. That store persisted nothing, adopted no sibling
|
|
139
|
+
# worker's push, and reported itself healthy; `Findxpand.store` has the full
|
|
140
|
+
# account of what that costs, and it is the worst outcome in the system
|
|
141
|
+
# because it silently destroys work the customer approved. The same hole
|
|
142
|
+
# swallowed `FINDXPAND_ENABLED=false` on those paths — the off switch the
|
|
143
|
+
# README promises, ignored by two of the three ways in.
|
|
144
|
+
#
|
|
145
|
+
# Matched to `middleware/php/src/Middleware.php:97-106`, the sibling package
|
|
146
|
+
# with the same three-ways-in problem, which resolves it in one line:
|
|
147
|
+
# `$options = array_merge( Auto::options(), $options )`, docblocked as
|
|
148
|
+
# "defaults come from the environment, so a container binding with no
|
|
149
|
+
# arguments behaves like the bootstrap". §20.1 rule 2 — one reading of the
|
|
150
|
+
# environment, and as many callers as the gem has entry points.
|
|
151
|
+
#
|
|
152
|
+
# **`nil` is the sentinel for "not given" on the three flags**, rather than
|
|
153
|
+
# `true`. An explicit `enabled: false` from a caller has to beat
|
|
154
|
+
# `FINDXPAND_ENABLED=1`, and a keyword defaulting to `true` cannot tell the
|
|
155
|
+
# two apart. `token: ''` is preserved as empty for the same reason (`'' ||
|
|
156
|
+
# x` is `''` in Ruby, since an empty String is truthy) — a caller can shut
|
|
157
|
+
# the admin endpoints without unsetting the environment.
|
|
158
|
+
#
|
|
159
|
+
# An empty token leaves those endpoints answering 401 to everybody, which is
|
|
160
|
+
# the safe direction: the alternative is an unauthenticated manifest
|
|
161
|
+
# endpoint on a customer's origin.
|
|
162
|
+
#
|
|
163
|
+
# `store:` and `rewriter:` are seams for the suite, not options. The rewriter
|
|
164
|
+
# exists because a `rescue` nothing can reach is a `rescue` nobody has tested
|
|
165
|
+
# — §20.1 rule 3 applied to our own error handling — and the only way to
|
|
166
|
+
# reach this one is to hand the middleware something that raises.
|
|
167
|
+
def initialize(app, token: nil, cache_file: nil, manifest_file: nil,
|
|
168
|
+
enabled: nil, max_bytes: nil, require_signature: nil,
|
|
169
|
+
recompress: nil, store: nil, rewriter: Rewrite, clock: nil)
|
|
170
|
+
settings = Findxpand.options_from_env
|
|
171
|
+
@app = app
|
|
172
|
+
@token = (token || settings[:token]).to_s
|
|
173
|
+
@enabled = enabled.nil? ? settings[:enabled] : enabled
|
|
174
|
+
# `options_from_env` omits `:max_bytes` when the variable is absent or
|
|
175
|
+
# unparseable, so the default lands here and a garbled `FINDXPAND_MAX_BYTES`
|
|
176
|
+
# cannot silently set the ceiling to zero and skip every page as too large.
|
|
177
|
+
@max_bytes = (max_bytes || settings[:max_bytes] || DEFAULT_MAX_BYTES).to_i
|
|
178
|
+
# The escape hatch exists for one case: an intermediary that rewrites or
|
|
179
|
+
# re-encodes request bodies, which invalidates any signature over them.
|
|
180
|
+
# Turn it off there and nowhere else — an unsigned push is authenticated
|
|
181
|
+
# by the bearer token alone, and a bearer token is replayable.
|
|
182
|
+
@require_signature = require_signature.nil? ? settings[:require_signature] : require_signature
|
|
183
|
+
# Ask the application for uncompressed HTML on the pages we rewrite, and
|
|
184
|
+
# compress them again on the way out. Off is for an intermediary that has
|
|
185
|
+
# already taken over the negotiation.
|
|
186
|
+
@recompress = recompress.nil? ? settings[:recompress] : recompress
|
|
187
|
+
@rewriter = rewriter
|
|
188
|
+
@store = store || Findxpand.store(cache_file: cache_file || settings[:cache_file],
|
|
189
|
+
manifest_file: manifest_file || settings[:manifest_file],
|
|
190
|
+
clock: clock)
|
|
191
|
+
@admin = Admin.new(store: @store, token: @token, max_bytes: @max_bytes,
|
|
192
|
+
require_signature: @require_signature, clock: clock)
|
|
193
|
+
# Counted last, so a constructor that raised is not counted as an attach
|
|
194
|
+
# that worked.
|
|
195
|
+
self.class.count_construction
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def call(env)
|
|
199
|
+
path = env['PATH_INFO'].to_s
|
|
200
|
+
path = '/' if path.empty?
|
|
201
|
+
|
|
202
|
+
# A second instance of this gem is already in the stack and has already
|
|
203
|
+
# decided what happens to this response. Leaving now is what makes the
|
|
204
|
+
# Railtie and the builder prepend safe to have on at the same time.
|
|
205
|
+
return @app.call(env) if env[SEEN_KEY]
|
|
206
|
+
|
|
207
|
+
env[SEEN_KEY] = true
|
|
208
|
+
|
|
209
|
+
# Has a sibling worker been pushed a manifest this process has not seen?
|
|
210
|
+
# Throttled to once a second inside the store; on a server that does not
|
|
211
|
+
# fork and on one with no cache file it does nothing at all. Asked before
|
|
212
|
+
# the admin branch as well, so a monitor polling `/status` on worker three
|
|
213
|
+
# is told what worker one was pushed.
|
|
214
|
+
@store.reload_if_stale
|
|
215
|
+
|
|
216
|
+
return @admin.call(env, path) if Admin.admin_path?(path)
|
|
217
|
+
|
|
218
|
+
# Counted before any decision about this request, and never for the admin
|
|
219
|
+
# surface. See `Store#saw_request`: a probe that answers its own question
|
|
220
|
+
# is an auditor that cannot fail.
|
|
221
|
+
@store.saw_request
|
|
222
|
+
|
|
223
|
+
return @app.call(env) unless @enabled
|
|
224
|
+
|
|
225
|
+
redirect = @store.redirect_for(path)
|
|
226
|
+
return serve_redirect(redirect) if redirect
|
|
227
|
+
|
|
228
|
+
manifest = @store.get
|
|
229
|
+
return serve_robots(manifest) if path == '/robots.txt' && present?(manifest['robots_txt'])
|
|
230
|
+
|
|
231
|
+
rule = @store.rule_for(path)
|
|
232
|
+
if rule.nil?
|
|
233
|
+
# Counted here and nowhere else: this is the "the manifest does not name
|
|
234
|
+
# this path" branch, and it is the third value that makes `considered:
|
|
235
|
+
# 0` readable. A manifest whose keys match nothing produces exactly the
|
|
236
|
+
# same counters as a middleware nobody has sent a request to yet, which
|
|
237
|
+
# is how the `normalise_path` disagreement stayed invisible for a
|
|
238
|
+
# fortnight while `/status` reported healthy. Ported from
|
|
239
|
+
# `middleware/node/src/index.ts:238-256` and `manifest.ts:274-284`,
|
|
240
|
+
# counter and semantics unchanged, because an operator alerting on
|
|
241
|
+
# `counters.unmatched > 0 && counters.considered == 0` — the condition
|
|
242
|
+
# the engine's own `degraded_reason` docstring names as the honest
|
|
243
|
+
# detector — was evaluating `nil > 0` against a Ruby origin.
|
|
244
|
+
#
|
|
245
|
+
# A request, not a response, and that is the honest reading: we leave
|
|
246
|
+
# before the application is called, so nothing here knows whether the
|
|
247
|
+
# response would have been HTML. Images, CSS and favicons are counted
|
|
248
|
+
# alongside pages, exactly as they are in Node.
|
|
249
|
+
@store.miss
|
|
250
|
+
return @app.call(env)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# A status rule answers before the application is reached. That is the
|
|
254
|
+
# point: the page still exists and still renders, and answering 200 is
|
|
255
|
+
# exactly the defect being corrected.
|
|
256
|
+
code = rule['status_code']
|
|
257
|
+
return serve_status(code) if code.is_a?(Integer) && ALLOWED_STATUS.include?(code)
|
|
258
|
+
|
|
259
|
+
# From here the response is ours to rewrite, so it is ours to encode — and
|
|
260
|
+
# ours to make sure the application actually produces. Only on this branch:
|
|
261
|
+
# every path above either never reaches the application or is not a page we
|
|
262
|
+
# touch, and those keep the application's own compression and conditional
|
|
263
|
+
# handling exactly as they were.
|
|
264
|
+
coding = ''
|
|
265
|
+
if @recompress
|
|
266
|
+
coding = Encoding.negotiate(env['HTTP_ACCEPT_ENCODING'])
|
|
267
|
+
env['HTTP_ACCEPT_ENCODING'] = 'identity' unless coding.empty?
|
|
268
|
+
end
|
|
269
|
+
CONDITIONAL_ENV.each { |key| env.delete(key) }
|
|
270
|
+
|
|
271
|
+
rewrite(env, rule, coding)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
private
|
|
275
|
+
|
|
276
|
+
def present?(value)
|
|
277
|
+
value.is_a?(String) && !value.empty?
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# `redirect['to']` is a non-empty String or this method is never reached:
|
|
281
|
+
# `Store#redirect_for` skips an entry that does not carry one, matching
|
|
282
|
+
# `middleware/php/src/Store.php:333-356`. Written without a `.to_s` on
|
|
283
|
+
# purpose, so the guarantee is visible here rather than papered over — a
|
|
284
|
+
# `.to_s` was what turned `{"from":"/old-beans","to":null}` into a live 301
|
|
285
|
+
# to nowhere, serving a dead page where the application would have rendered
|
|
286
|
+
# a working one and counting it as a healthy `redirect`. §3 rule 7's closed
|
|
287
|
+
# direction for a malformed manifest is "serve the page unchanged"; it is
|
|
288
|
+
# not "emit a redirect to an empty location". Python raises on
|
|
289
|
+
# `str(redirect["to"])` and Node throws `ERR_HTTP_INVALID_HEADER_VALUE`, so
|
|
290
|
+
# both of them failed loudly where this failed silently — the one direction
|
|
291
|
+
# this package is not allowed to differ in.
|
|
292
|
+
def serve_redirect(redirect)
|
|
293
|
+
status = redirect['status']
|
|
294
|
+
status = 301 unless REDIRECT_STATUS.include?(status)
|
|
295
|
+
@store.record('redirect')
|
|
296
|
+
[status,
|
|
297
|
+
{ 'location' => redirect['to'],
|
|
298
|
+
'content-length' => '0',
|
|
299
|
+
ORIGIN_HEADER => 'redirect' },
|
|
300
|
+
[]]
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def serve_robots(manifest)
|
|
304
|
+
sitemap = manifest['sitemap']
|
|
305
|
+
text = manifest['robots_txt'].to_s
|
|
306
|
+
text += "\nSitemap: #{sitemap}\n" if present?(sitemap)
|
|
307
|
+
@store.record('1')
|
|
308
|
+
[200,
|
|
309
|
+
{ 'content-type' => 'text/plain; charset=utf-8',
|
|
310
|
+
'content-length' => text.bytesize.to_s,
|
|
311
|
+
ORIGIN_HEADER => '1',
|
|
312
|
+
VERSION_HEADER => manifest['version'].to_s },
|
|
313
|
+
[text]]
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def serve_status(code)
|
|
317
|
+
body = STATUS_BODY.fetch(code)
|
|
318
|
+
@store.record('status')
|
|
319
|
+
[code,
|
|
320
|
+
{ 'content-type' => 'text/plain; charset=utf-8',
|
|
321
|
+
'content-length' => body.bytesize.to_s,
|
|
322
|
+
ORIGIN_HEADER => 'status',
|
|
323
|
+
VERSION_HEADER => @store.get['version'].to_s },
|
|
324
|
+
[body]]
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def rewrite(env, rule, coding)
|
|
328
|
+
status, headers, body = @app.call(env)
|
|
329
|
+
# A Rack application may return a frozen header hash, and Rack 3 says the
|
|
330
|
+
# caller owns what it is given. Duplicated once, here, so no branch below
|
|
331
|
+
# has to remember.
|
|
332
|
+
headers = headers.nil? ? {} : headers.dup
|
|
333
|
+
|
|
334
|
+
# A 404's title is not ours to correct, and a 500 is not a page to decorate
|
|
335
|
+
# while somebody is trying to debug it.
|
|
336
|
+
return passthrough(status, headers, body, '', coding) unless status.to_i == 200
|
|
337
|
+
return passthrough(status, headers, body, '', coding) unless html?(headers)
|
|
338
|
+
|
|
339
|
+
# Something already compressed this despite our asking for identity.
|
|
340
|
+
# Rewriting encoded bytes produces a broken page.
|
|
341
|
+
if present?(header(headers, 'content-encoding'))
|
|
342
|
+
return passthrough(status, headers, body, 'skip-encoded', coding)
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
declared = header(headers, 'content-length').to_s
|
|
346
|
+
if !declared.empty? && declared.to_i > @max_bytes
|
|
347
|
+
return passthrough(status, headers, body, 'skip-large', coding)
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# A Rack 3 streaming body — one that responds to `call` and not to `each` —
|
|
351
|
+
# cannot be buffered without changing what streaming means, so it is served
|
|
352
|
+
# exactly as it arrived. Marked `skip-large` rather than given a marker of
|
|
353
|
+
# its own: the two mean the same thing to a reader ("we declined to hold
|
|
354
|
+
# this response"), and a Ruby-only counter key would put a field in
|
|
355
|
+
# `/status` that the engine's parser and the other two packages have never
|
|
356
|
+
# seen.
|
|
357
|
+
unless body.respond_to?(:each)
|
|
358
|
+
return passthrough(status, headers, body, 'skip-large', coding)
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
chunks, rest, size = buffer(body)
|
|
362
|
+
if !rest.nil? || size > @max_bytes
|
|
363
|
+
# Too large to hold. Served whole rather than truncated: what was already
|
|
364
|
+
# pulled off the body is yielded first and the remainder streams on. The
|
|
365
|
+
# size is re-checked here as well as inside `buffer` because a body that
|
|
366
|
+
# answered `to_ary` was whole before we ever looked at it, so nothing in
|
|
367
|
+
# the loop ever ran — and most Ruby applications return exactly that.
|
|
368
|
+
return passthrough(status, headers, Remainder.new(chunks, rest, body), 'skip-large', coding)
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# Joined as bytes. `['<html>'.b, 'مرحبا'].join` raises
|
|
372
|
+
# `Encoding::CompatibilityError` on a body whose chunks came from different
|
|
373
|
+
# places — a template fragment read off a socket beside one built in
|
|
374
|
+
# memory — and a raise here is a 500 on the customer's homepage rather than
|
|
375
|
+
# a page we declined to rewrite. Bytes have no compatibility question.
|
|
376
|
+
original = chunks.map { |chunk| chunk.to_s.b }.join
|
|
377
|
+
text = original.dup.force_encoding('UTF-8')
|
|
378
|
+
# A page we cannot decode is a page we do not touch. Node cannot reach this
|
|
379
|
+
# branch — `Buffer.toString('utf8')` substitutes replacement characters
|
|
380
|
+
# rather than reporting — which is why its counter table has eight keys and
|
|
381
|
+
# ours has nine.
|
|
382
|
+
unless text.valid_encoding?
|
|
383
|
+
held = Remainder.new(chunks, nil, body)
|
|
384
|
+
return passthrough(status, headers, held, 'skip-encoding', coding)
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
begin
|
|
388
|
+
out = @rewriter.transform(text, rule)
|
|
389
|
+
rescue StandardError
|
|
390
|
+
# A rewrite that raises serves the original bytes. This is the whole
|
|
391
|
+
# safety argument for buffering: the client's page is never lost to a bug
|
|
392
|
+
# of ours. Marked `error` rather than left to read as `pass` — those are
|
|
393
|
+
# the same bytes and opposite problems, and reporting a crashing rewrite
|
|
394
|
+
# as a clean no-op is exactly the invisible failure the counters exist to
|
|
395
|
+
# end.
|
|
396
|
+
return passthrough(status, headers, Remainder.new(chunks, nil, body), 'error', coding)
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
finish(status, headers, body, original, out, coding)
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# Send what the rewrite produced.
|
|
403
|
+
#
|
|
404
|
+
# `marker` is decided from the text, before anything is compressed, so
|
|
405
|
+
# `x-findxpand-origin-mw` describes what we did to the *page* rather than
|
|
406
|
+
# what we did to the bytes.
|
|
407
|
+
def finish(status, headers, body, original, out, coding)
|
|
408
|
+
# Both sides binary before they are compared. `String#==` is false for two
|
|
409
|
+
# strings that hold the same bytes under different encodings once either
|
|
410
|
+
# leaves ASCII, so comparing the UTF-8 rewrite against the binary original
|
|
411
|
+
# would answer "changed" for every Arabic page we did not change — a `1`
|
|
412
|
+
# marker, an `applied` counter and an `applied_age` on a rewrite that did
|
|
413
|
+
# nothing (§14 again).
|
|
414
|
+
bytes = out.to_s.b
|
|
415
|
+
marker = bytes == original ? 'pass' : '1'
|
|
416
|
+
|
|
417
|
+
delete_header(headers, 'content-length')
|
|
418
|
+
# Nothing downstream is chunking a body we have already buffered whole.
|
|
419
|
+
delete_header(headers, 'transfer-encoding')
|
|
420
|
+
if marker == '1'
|
|
421
|
+
# The application computed this over the document it produced, and the
|
|
422
|
+
# document that leaves here is a different one. A stale validator is not
|
|
423
|
+
# a cosmetic problem: a cache or a browser holding the old page would go
|
|
424
|
+
# on being told it is current, so the fix would be live for new visitors
|
|
425
|
+
# and invisible to returning ones. Dropped rather than recomputed —
|
|
426
|
+
# revalidating costs a request, serving the wrong page costs the fix.
|
|
427
|
+
delete_header(headers, 'etag')
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
unless coding.empty?
|
|
431
|
+
add_vary(headers)
|
|
432
|
+
unless Encoding.no_transform?(header(headers, 'cache-control'))
|
|
433
|
+
packed = Encoding.encode(bytes, coding)
|
|
434
|
+
unless packed.nil?
|
|
435
|
+
bytes = packed
|
|
436
|
+
headers['content-encoding'] = coding
|
|
437
|
+
end
|
|
438
|
+
end
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
# **`bytesize`, never `length`.** `length` counts characters, so on the
|
|
442
|
+
# Arabic pages this product exists to fix (§14) a content-length taken from
|
|
443
|
+
# it is roughly half the bytes actually sent, and the client is served a
|
|
444
|
+
# truncated document — valid-looking HTML that simply stops. The rewrite
|
|
445
|
+
# layer counts in characters throughout and the HTTP layer counts in bytes
|
|
446
|
+
# throughout; this line is the boundary between the two.
|
|
447
|
+
headers['content-length'] = bytes.bytesize.to_s
|
|
448
|
+
headers[ORIGIN_HEADER] = marker
|
|
449
|
+
headers[VERSION_HEADER] = @store.get['version'].to_s
|
|
450
|
+
@store.record(marker)
|
|
451
|
+
close(body)
|
|
452
|
+
[status, headers, [bytes]]
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def passthrough(status, headers, body, marker, coding)
|
|
456
|
+
unless marker.empty?
|
|
457
|
+
headers[ORIGIN_HEADER] = marker
|
|
458
|
+
@store.record(marker)
|
|
459
|
+
end
|
|
460
|
+
# We asked for identity on this request, so the response varies by what the
|
|
461
|
+
# client sent even though we did not compress it.
|
|
462
|
+
add_vary(headers) unless coding.empty?
|
|
463
|
+
[status, headers, body]
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
# `[chunks, remainder_or_nil, size]`.
|
|
467
|
+
#
|
|
468
|
+
# A body that answers `to_ary` is already whole in memory, so there is
|
|
469
|
+
# nothing to stream and no `Enumerator` to build. Everything else is pulled
|
|
470
|
+
# one chunk at a time through `to_enum(:each)`, which is the only construct
|
|
471
|
+
# in Ruby with the pull semantics this needs: `each` is a push, so a
|
|
472
|
+
# middleware that discovers halfway through that a body is too large has no
|
|
473
|
+
# way to hand the rest back unless it is holding an enumerator over it.
|
|
474
|
+
#
|
|
475
|
+
# The ceiling is checked inside the loop *and* against `content-length`
|
|
476
|
+
# before we get here, because most Ruby applications return their whole page
|
|
477
|
+
# as one chunk and would never reach the second iteration.
|
|
478
|
+
#
|
|
479
|
+
# ## What this costs, stated rather than fixed
|
|
480
|
+
#
|
|
481
|
+
# `to_enum(:each)` plus `next` is external iteration, and Ruby implements it
|
|
482
|
+
# with a **Fiber**. If the `Remainder` we hand back is never drained — a
|
|
483
|
+
# client that disconnects after the first chunk of a 3 MB export — that Fiber
|
|
484
|
+
# stays suspended for good, and a Fiber that is garbage collected without
|
|
485
|
+
# being resumed to completion does **not** run the `ensure` blocks inside the
|
|
486
|
+
# `each` it was in the middle of. Python's equivalent (`wsgi.py`'s `_chain`)
|
|
487
|
+
# has no such hazard: it is a plain generator over an already-`iter()`ed
|
|
488
|
+
# iterable.
|
|
489
|
+
#
|
|
490
|
+
# It is left as it is, and three things bound it:
|
|
491
|
+
#
|
|
492
|
+
# 1. `close` **is** forwarded (`Remainder#close`), and Rack's own contract is
|
|
493
|
+
# that a body releases what it holds in `close` — the server is required
|
|
494
|
+
# to call it and is not required to finish iterating. A body that does its
|
|
495
|
+
# cleanup only in an `ensure` inside `each` is already outside that
|
|
496
|
+
# contract; on Rails the executor and the database connection go back
|
|
497
|
+
# through `Rack::BodyProxy#close`, which we do call.
|
|
498
|
+
# 2. Nothing gets here unless the response declared no `content-length` **and**
|
|
499
|
+
# exceeded the ceiling, which is the chunked-and-large case alone.
|
|
500
|
+
# 3. Every alternative is worse. Draining the remainder inside `close` would
|
|
501
|
+
# run those `ensure` blocks — and would hang for ever on an endless body
|
|
502
|
+
# (`ActionController::Live`, Server-Sent Events), which is a hung worker
|
|
503
|
+
# rather than an unreleased object. Buffering past the ceiling is the
|
|
504
|
+
# ceiling deleted. Not handing the rest back at all truncates the
|
|
505
|
+
# customer's page, which is the one thing this whole path exists to
|
|
506
|
+
# prevent.
|
|
507
|
+
#
|
|
508
|
+
# **Unverified: no Ruby on the machine this was written on.** The Fiber
|
|
509
|
+
# semantics above are from the language documentation, not from a run.
|
|
510
|
+
def buffer(body)
|
|
511
|
+
if body.respond_to?(:to_ary)
|
|
512
|
+
# Asked once. `to_ary` is a conversion, not a reader, and a body entitled
|
|
513
|
+
# to build its array on demand would build two.
|
|
514
|
+
whole = body.to_ary
|
|
515
|
+
return [whole, nil, whole.sum { |chunk| chunk.to_s.bytesize }]
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
chunks = []
|
|
519
|
+
size = 0
|
|
520
|
+
enum = body.to_enum(:each)
|
|
521
|
+
loop do
|
|
522
|
+
chunk = enum.next.to_s
|
|
523
|
+
chunks << chunk
|
|
524
|
+
size += chunk.bytesize
|
|
525
|
+
return [chunks, enum, size] if size > @max_bytes
|
|
526
|
+
end
|
|
527
|
+
[chunks, nil, size]
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
# What was buffered, then whatever is left, then close what we borrowed.
|
|
531
|
+
#
|
|
532
|
+
# Rack calls `close` on the body it is given and never on the one we took it
|
|
533
|
+
# from, so the original's `close` has to be forwarded — on Rails that is
|
|
534
|
+
# where the executor hands back the database connection.
|
|
535
|
+
class Remainder
|
|
536
|
+
def initialize(chunks, rest, original)
|
|
537
|
+
@chunks = chunks
|
|
538
|
+
@rest = rest
|
|
539
|
+
@original = original
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
def each
|
|
543
|
+
@chunks.each { |chunk| yield chunk }
|
|
544
|
+
return if @rest.nil?
|
|
545
|
+
|
|
546
|
+
loop { yield @rest.next }
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
def close
|
|
550
|
+
@original.close if @original.respond_to?(:close)
|
|
551
|
+
end
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
def close(body)
|
|
555
|
+
body.close if body.respond_to?(:close)
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
def html?(headers)
|
|
559
|
+
header(headers, 'content-type').to_s.include?('text/html')
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
# Read a header without caring how the application spelled it.
|
|
563
|
+
#
|
|
564
|
+
# Rack 3 requires lower case, Rack 2 conventionally uses `Content-Type`, and
|
|
565
|
+
# a Rack 2 app may hand back a `Rack::Utils::HeaderHash`, which is already
|
|
566
|
+
# case-insensitive. A plain `headers['content-type']` is correct under
|
|
567
|
+
# exactly one of those three and silently wrong under the others: it reads an
|
|
568
|
+
# empty content type, decides the response is not HTML, and every page on
|
|
569
|
+
# that app is served untouched, uncounted and unmarked — health with the
|
|
570
|
+
# lights off, which is the failure the Node package shipped for `writeHead`.
|
|
571
|
+
def header(headers, name)
|
|
572
|
+
value = headers[name]
|
|
573
|
+
if value.nil?
|
|
574
|
+
key = headers.keys.find { |candidate| candidate.to_s.downcase == name }
|
|
575
|
+
value = key.nil? ? nil : headers[key]
|
|
576
|
+
end
|
|
577
|
+
# Some Rack 2 servers accept an Array of values for a repeated header.
|
|
578
|
+
value.is_a?(Array) ? value.join(', ') : value
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
def delete_header(headers, name)
|
|
582
|
+
headers.delete(name)
|
|
583
|
+
key = headers.keys.find { |candidate| candidate.to_s.downcase == name }
|
|
584
|
+
headers.delete(key) unless key.nil?
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
# `Vary: Accept-Encoding`, spliced in under whatever casing is already there.
|
|
588
|
+
def add_vary(headers)
|
|
589
|
+
existing = header(headers, 'vary')
|
|
590
|
+
wanted = Encoding.vary_value(existing)
|
|
591
|
+
return if wanted.empty?
|
|
592
|
+
|
|
593
|
+
key = headers.keys.find { |candidate| candidate.to_s.downcase == 'vary' } || 'vary'
|
|
594
|
+
headers[key] = wanted
|
|
595
|
+
end
|
|
596
|
+
end
|
|
597
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Rails, at position 0 of its own middleware stack.
|
|
4
|
+
#
|
|
5
|
+
# Loaded by `lib/findxpand.rb` when Rails is already defined, which is what a
|
|
6
|
+
# Gemfile entry produces: Bundler requires the gems in Gemfile order, so `gem
|
|
7
|
+
# 'findxpand'` after `gem 'rails'` lands here. Under `RUBYOPT="-rfindxpand/auto"`
|
|
8
|
+
# it does *not* — that runs before Rails exists — and it does not need to, since
|
|
9
|
+
# `auto.rb` attaches one layer further out.
|
|
10
|
+
#
|
|
11
|
+
# ## Why position 0 is enough on Rails
|
|
12
|
+
#
|
|
13
|
+
# `insert_before 0` is the outermost entry of the Rails stack: the stack is folded
|
|
14
|
+
# from the end, so index 0 wraps everything after it, including a
|
|
15
|
+
# `config.middleware.use Rack::Deflater`. What it does *not* wrap is anything
|
|
16
|
+
# `config.ru` itself `use`d, which sits outside `Rails.application` — and that
|
|
17
|
+
# turns out not to matter, because a `Rack::Deflater` out there sees the
|
|
18
|
+
# `Accept-Encoding: identity` this middleware wrote into the env on its way down,
|
|
19
|
+
# and declines a response that already carries a `content-encoding`. Either way
|
|
20
|
+
# it stands aside, and the page it is handed is the corrected one.
|
|
21
|
+
#
|
|
22
|
+
# So a Rails application needs nothing but the Gemfile line. `RUBYOPT` is still
|
|
23
|
+
# the recommended install because it is the same instruction for every framework
|
|
24
|
+
# and because it survives a `config.ru` that does something unusual.
|
|
25
|
+
#
|
|
26
|
+
# ## Not a place to be clever
|
|
27
|
+
#
|
|
28
|
+
# This file patches no builder class. Adding a gem to a Gemfile should insert a
|
|
29
|
+
# middleware — every Rails developer expects that — and should not monkey-patch
|
|
30
|
+
# Rack behind their back; that is what asking for `findxpand/auto` by name
|
|
31
|
+
# means. The one exception is re-arming a hook that
|
|
32
|
+
# `auto.rb` already installed, which is a no-op unless `RUBYOPT` ran and Rack was
|
|
33
|
+
# somehow not loadable at the time.
|
|
34
|
+
#
|
|
35
|
+
# Frozen string literals: on.
|
|
36
|
+
|
|
37
|
+
# A top-level `return` in a required file, so that `require
|
|
38
|
+
# 'findxpand/railtie'` by hand outside Rails is inert rather than a NameError.
|
|
39
|
+
return unless defined?(::Rails::Railtie)
|
|
40
|
+
|
|
41
|
+
require 'findxpand'
|
|
42
|
+
|
|
43
|
+
module Findxpand
|
|
44
|
+
class Railtie < ::Rails::Railtie
|
|
45
|
+
initializer 'findxpand.middleware', before: :build_middleware_stack do |app|
|
|
46
|
+
# Only if `findxpand/auto` was loaded. Late, but not too late: Rails
|
|
47
|
+
# initializers run while `config.ru` is being read and before the builder
|
|
48
|
+
# calls `to_app`. Re-armed here because Rack is certainly loaded by now
|
|
49
|
+
# even if it was not when `RUBYOPT` ran; the shape watcher in `auto.rb`
|
|
50
|
+
# covers the builder a server defines for itself, which this cannot reach.
|
|
51
|
+
Findxpand::Auto.attach_builders if defined?(Findxpand::Auto)
|
|
52
|
+
|
|
53
|
+
if ENV['FINDXPAND_TOKEN'].to_s.empty?
|
|
54
|
+
# Silent rather than noisy. A Rails process boots this file in
|
|
55
|
+
# `rails console`, `rails runner`, `rake` and every CI job, and a gem
|
|
56
|
+
# that writes to stderr in all of them is a gem somebody removes.
|
|
57
|
+
# `auto.rb` warns instead, because a `RUBYOPT` was a deliberate act.
|
|
58
|
+
next
|
|
59
|
+
end
|
|
60
|
+
next unless Findxpand.flag('FINDXPAND_ENABLED', true)
|
|
61
|
+
|
|
62
|
+
begin
|
|
63
|
+
app.middleware.insert_before 0, Findxpand::Middleware
|
|
64
|
+
rescue StandardError
|
|
65
|
+
# `insert_before 0` is the documented spelling and `unshift` is the one
|
|
66
|
+
# older stacks answer to. Both mean outermost; neither is worth failing
|
|
67
|
+
# a boot over.
|
|
68
|
+
app.middleware.unshift Findxpand::Middleware
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|