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.
@@ -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