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,607 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Attaching without editing the application.
|
|
4
|
+
#
|
|
5
|
+
# RUBYOPT="-rfindxpand/auto" bundle exec puma
|
|
6
|
+
# RUBYOPT="-rfindxpand/auto" bundle exec rails server
|
|
7
|
+
# RUBYOPT="-rfindxpand/auto" bundle exec unicorn
|
|
8
|
+
#
|
|
9
|
+
# Ruby's `-r` is Node's `--require` and Python's `sitecustomize`: it runs before
|
|
10
|
+
# the application's first line, which is the only place from which a middleware
|
|
11
|
+
# can guarantee its own position.
|
|
12
|
+
#
|
|
13
|
+
# ## Why this exists, rather than one more line in the install guide
|
|
14
|
+
#
|
|
15
|
+
# `use Findxpand::Middleware` asks a customer to place a middleware correctly in
|
|
16
|
+
# a stack we cannot see, and the instruction we shipped for doing it was **wrong
|
|
17
|
+
# in every language it was written for**. Measured 31 Aug 2026:
|
|
18
|
+
#
|
|
19
|
+
# Express + compression, findxpand first marker `skip-encoded`, page
|
|
20
|
+
# unmodified, `/status` healthy
|
|
21
|
+
# Express + compression, findxpand last 2069 bytes of plain HTML on the
|
|
22
|
+
# wire under `content-encoding: gzip`
|
|
23
|
+
# Django 6.1 + GZipMiddleware, outermost marker `skip-encoded`, original
|
|
24
|
+
# title served, `/status` healthy
|
|
25
|
+
#
|
|
26
|
+
# Rack has the same shape as the Django case and the same only-one-position:
|
|
27
|
+
# `use Rack::Deflater` inside us hands us gzip, and there is nowhere else to
|
|
28
|
+
# stand. This was never a step people were getting wrong — it is a step with no
|
|
29
|
+
# right answer, which is why the answer is to delete the step. `encoding.rb`
|
|
30
|
+
# takes over the negotiation so that outermost is finally the correct place, and
|
|
31
|
+
# this file makes outermost happen without anybody typing anything.
|
|
32
|
+
#
|
|
33
|
+
# ## The hook: every builder, not one class
|
|
34
|
+
#
|
|
35
|
+
# **A Rack builder, prepended — whichever class the server actually uses.**
|
|
36
|
+
#
|
|
37
|
+
# What used to be written here was: "`Rack::Builder#to_app`, prepended. Every
|
|
38
|
+
# Ruby web server that serves a Rack application builds it through that one
|
|
39
|
+
# method." **That is false, and it was false on the server named in our own
|
|
40
|
+
# headline install command.** Puma does not parse `config.ru` with
|
|
41
|
+
# `Rack::Builder`. It carries a vendored copy — `Puma::Rack::Builder`, in
|
|
42
|
+
# `puma/lib/puma/rack/builder.rb`, reached from `Puma::Configuration#load_rackup`
|
|
43
|
+
# — whose `new_from_string` evaluates `Puma::Rack::Builder.new {...}.to_app`.
|
|
44
|
+
# A prepend on `::Rack::Builder` is not on that class and never runs, so
|
|
45
|
+
# `RUBYOPT="-rfindxpand/auto" bundle exec puma` placed a hook, warned about
|
|
46
|
+
# nothing, and attached nothing. `/__findxpand/status` then 404s and the engine
|
|
47
|
+
# reports `installed: false` for a package that is installed and did run: the
|
|
48
|
+
# attach that silently does not attach, §20.1 rule 3, in the one place it costs
|
|
49
|
+
# the most.
|
|
50
|
+
#
|
|
51
|
+
# **MEASURED 2 Sep 2026 on Ruby 3.4.10, Puma 8.0.2, rack 3.2.7 — and the claim
|
|
52
|
+
# above is half right, in a way that matters.** Puma 8 does ship the vendored
|
|
53
|
+
# copy: `puma/rack/builder.rb` is in the gem and `require "puma/rack/builder"`
|
|
54
|
+
# defines `Puma::Rack::Builder`. But it is NOT loaded when the `rack` gem is
|
|
55
|
+
# available, and with rack present Puma parses `config.ru` with the real
|
|
56
|
+
# `::Rack::Builder`. Booting a bare non-Rails `config.ru` under
|
|
57
|
+
# `RUBYOPT="-rfindxpand/auto" puma -b tcp://127.0.0.1:8782` gave
|
|
58
|
+
# `attach_state: "attached", builders: ["Rack::Builder"], built: 1,
|
|
59
|
+
# watching: false` — the by-name prepend caught it and the watcher never fired.
|
|
60
|
+
# An approved title then landed in the served HTML and `origin-confirm` reported
|
|
61
|
+
# `deployed: true`.
|
|
62
|
+
#
|
|
63
|
+
# So the vendored builder is Puma's rack-less FALLBACK rather than its normal
|
|
64
|
+
# path, and the original finding overstated it. The watcher is still the thing
|
|
65
|
+
# that covers that fallback, because the fallback is required lazily — after
|
|
66
|
+
# `findxpand/auto` has already run, which is precisely the case a prepend by
|
|
67
|
+
# name cannot reach. That half is measured too: loading the real
|
|
68
|
+
# `Puma::Rack::Builder` after us and building an app through it returns a
|
|
69
|
+
# `Findxpand::Middleware`, so the TracePoint catches Puma's own class by shape.
|
|
70
|
+
#
|
|
71
|
+
# Both mechanisms are therefore exercised, and neither is redundant:
|
|
72
|
+
#
|
|
73
|
+
# 1. **By name, for what is already loaded** (`BUILDER_CLASSES`). Cheap, exact,
|
|
74
|
+
# and enough for `rackup`, Unicorn, Thin and anything that reaches
|
|
75
|
+
# `Rack::Builder` before we run.
|
|
76
|
+
# 2. **By shape, for what is defined afterwards** (`watch_for_builders`). A
|
|
77
|
+
# class that defines `to_app`, `use` and `run` *is* a Rack builder whatever it
|
|
78
|
+
# is called and whoever vendored it. This catches Puma without our being right
|
|
79
|
+
# about `Puma::Rack::Builder`, and it catches the next server that vendors
|
|
80
|
+
# one.
|
|
81
|
+
#
|
|
82
|
+
# `super` first in every case, so the application is built exactly as it would
|
|
83
|
+
# have been and every `use` in the file is already inside what we are handed.
|
|
84
|
+
#
|
|
85
|
+
# ## Why a TracePoint now, when this file used to refuse one
|
|
86
|
+
#
|
|
87
|
+
# The rejected version was a `TracePoint` **waiting for `Rack::Builder` to be
|
|
88
|
+
# defined**, so that Rack need not be loadable at the moment this file runs. That
|
|
89
|
+
# is still refused, and for the reason written here before: `bundle exec`
|
|
90
|
+
# prepends `-rbundler/setup` to `RUBYOPT`, so the load path is already the
|
|
91
|
+
# Gemfile's by the time we run, and a Rack application's Gemfile contains Rack.
|
|
92
|
+
# It bought a case that does not occur.
|
|
93
|
+
#
|
|
94
|
+
# The case this one buys **always** occurs: the server defines its own builder
|
|
95
|
+
# *after* us, because the server is what `RUBYOPT` runs before. No amount of
|
|
96
|
+
# looking at the moment we load can see a class that does not exist yet, and
|
|
97
|
+
# requiring `puma/rack/builder` ourselves would mean loading a server's internals
|
|
98
|
+
# out of order on the strength of a filename we cannot check from here.
|
|
99
|
+
#
|
|
100
|
+
# What it costs, and this is reasoning rather than a measurement — nothing in
|
|
101
|
+
# this file has been executed: one Ruby-level callback per class or module body
|
|
102
|
+
# that *closes* (`:end`), which is thousands during a Rails boot and rare
|
|
103
|
+
# afterwards, doing an `is_a?` and up to three `method_defined?` calls. It is
|
|
104
|
+
# disarmed the moment an application is built through us, which is the moment it
|
|
105
|
+
# has nothing left to catch. If `TracePoint` cannot be enabled at all — another
|
|
106
|
+
# Ruby, a sandbox — that is **said on stderr** rather than discovered by a
|
|
107
|
+
# customer whose pages never changed.
|
|
108
|
+
#
|
|
109
|
+
# Three alternatives were considered and are not used alone:
|
|
110
|
+
#
|
|
111
|
+
# * **A Railtie inserting at position 0.** It is shipped (`railtie.rb`) and it is
|
|
112
|
+
# not sufficient: index 0 of the Rails stack is still *inside* whatever
|
|
113
|
+
# `config.ru` wrapped around `Rails.application`, and it reaches no Sinatra,
|
|
114
|
+
# Hanami or plain Rack application at all. It is the belt to this file's
|
|
115
|
+
# braces, and it matters in one real case — a Rails process where `RUBYOPT`
|
|
116
|
+
# never arrived but the Gemfile did.
|
|
117
|
+
# * **Patching the server.** Puma, Unicorn, Passenger, Falcon and WEBrick share
|
|
118
|
+
# no request path, no configuration object and no naming convention; a hook per
|
|
119
|
+
# server is five hooks to maintain and five ways to silently miss. Node could
|
|
120
|
+
# do it in one place because every one of its servers emits `request` on an
|
|
121
|
+
# `http.Server`. Ruby has no such single seam below Rack — but every one of
|
|
122
|
+
# them does build through *a* builder, which is what the shape test uses.
|
|
123
|
+
# * **Requiring the server's own builder file ourselves.** One line, and it
|
|
124
|
+
# depends on a path inside somebody else's gem being what we think it is, at
|
|
125
|
+
# the version they pinned, loadable in whatever order we ask for it. The shape
|
|
126
|
+
# test needs to be right about nothing.
|
|
127
|
+
#
|
|
128
|
+
# ## What it reaches, and what it does not
|
|
129
|
+
#
|
|
130
|
+
# Reached, through a builder's `to_app`:
|
|
131
|
+
#
|
|
132
|
+
# * anything booted from a `config.ru` — `rackup`, `puma`, `unicorn`, `thin`,
|
|
133
|
+
# `falcon`, Passenger, WEBrick;
|
|
134
|
+
# * **Rails**, because `rails server` and every one of those servers loads
|
|
135
|
+
# `config.ru`, and the wrap therefore sits outside the Rails stack *and*
|
|
136
|
+
# outside anything `config.ru` itself `use`d;
|
|
137
|
+
# * **Sinatra**, classic and modular, including `ruby app.rb` with no
|
|
138
|
+
# `config.ru`, because `Sinatra::Base.new` builds through `Rack::Builder`.
|
|
139
|
+
# Note what that does *not* mean: Sinatra builds through a builder of its own
|
|
140
|
+
# making, so we are outside everything `config.ru` used **and inside
|
|
141
|
+
# Sinatra's own `use` lines**. The claim that this is "strictly further out
|
|
142
|
+
# than any `use` line" holds for `config.ru` and does not hold for
|
|
143
|
+
# `Sinatra::Base.use`;
|
|
144
|
+
# * **Hanami**, **Roda**, **Grape**, `Rack::URLMap`, and a bare lambda.
|
|
145
|
+
#
|
|
146
|
+
# Reached, through the Railtie, and only when the gem is in the Gemfile so that
|
|
147
|
+
# it is required after Rails: the Rails middleware stack at position 0.
|
|
148
|
+
#
|
|
149
|
+
# **Not reached, and there is no point pretending otherwise:**
|
|
150
|
+
#
|
|
151
|
+
# * a server handed an app object in code rather than through a builder —
|
|
152
|
+
# `Puma::Server.new(MyApp).run`, `Rackup::Handler::WEBrick.run(MyApp)`;
|
|
153
|
+
# * a builder that does not answer to `to_app`, `use` and `run` together. That
|
|
154
|
+
# is the whole of what the shape test recognises, and a server that invents a
|
|
155
|
+
# different DSL is a server we do not see. `attach_state` and `built` say so
|
|
156
|
+
# (see below) rather than leaving it to be guessed;
|
|
157
|
+
# * anything that is not a Rack application: a bare `WEBrick::HTTPServer` with a
|
|
158
|
+
# servlet, `Async::HTTP::Server` used directly, gRPC, ActionCable's own socket
|
|
159
|
+
# path;
|
|
160
|
+
# * a process that never receives `RUBYOPT` — a `bin/rails` wrapper that
|
|
161
|
+
# re-execs, a systemd unit with a scrubbed `Environment=`, a Dockerfile that
|
|
162
|
+
# sets `ENV` in a build stage the runtime stage does not inherit. On Rails the
|
|
163
|
+
# Railtie still catches this; on Sinatra and Hanami nothing does;
|
|
164
|
+
# * **JRuby and TruffleRuby**, in the sense that the mechanism is ordinary Ruby
|
|
165
|
+
# and ought to work and has not been run there. If `TracePoint` is missing or
|
|
166
|
+
# refuses to enable, only the by-name half runs and stderr says so;
|
|
167
|
+
# * **the other workers.** After `fork` — Puma in cluster mode, Unicorn, Passenger
|
|
168
|
+
# — each worker holds its own copy of the manifest, and a push arrives at
|
|
169
|
+
# exactly one of them. `Store#reload_if_stale` closes that by re-reading the
|
|
170
|
+
# cache file when it changes, which is why `FINDXPAND_CACHE_FILE` is not
|
|
171
|
+
# optional on a forking server.
|
|
172
|
+
#
|
|
173
|
+
# ## How you can tell, without trusting any of the above
|
|
174
|
+
#
|
|
175
|
+
# An attach that silently does not attach is the worst outcome of all (§20.1
|
|
176
|
+
# rule 3), so nothing here reports on the strength of a hook it *placed*. Every
|
|
177
|
+
# field is something this process *observed*:
|
|
178
|
+
#
|
|
179
|
+
# attach_state pending / no-token / disabled / no-builder / watching /
|
|
180
|
+
# attached / failed — modelled on
|
|
181
|
+
# `middleware/php/src/Auto.php:135-155`, which made the same
|
|
182
|
+
# change for the same reason: "`attached()` alone is a boolean
|
|
183
|
+
# with four causes behind it"
|
|
184
|
+
# builders the classes actually prepended, by name
|
|
185
|
+
# built how many applications were built through one of them
|
|
186
|
+
# middlewares how many `Findxpand::Middleware` objects exist
|
|
187
|
+
# attach_problem the sentence, when `built` or `middlewares` is zero
|
|
188
|
+
#
|
|
189
|
+
# They are reported by `GET /__findxpand/status` (`Findxpand.attach_report`,
|
|
190
|
+
# merged in by `Admin`). `built: 0` there is the exact signature of the Puma
|
|
191
|
+
# defect above: the Railtie mounted us, so the endpoint answers, and the
|
|
192
|
+
# `RUBYOPT` hook never fired.
|
|
193
|
+
#
|
|
194
|
+
# **The one case this cannot report is the total one**, and it is named rather
|
|
195
|
+
# than papered over: if nothing of ours was ever constructed there is no
|
|
196
|
+
# middleware in the path to answer `/status` at all. Stderr is then the only
|
|
197
|
+
# channel, and it carries every cause that can be observed at boot — no token,
|
|
198
|
+
# no builder, a `TracePoint` that would not enable. What remains unobservable is
|
|
199
|
+
# a process that boots cleanly and simply never builds an application through
|
|
200
|
+
# anything we recognise; nothing in-process can distinguish that from a server
|
|
201
|
+
# that has not finished booting, and inventing a timer that guessed would be a
|
|
202
|
+
# monitor that cries wolf on every Puma master and every `rake` task.
|
|
203
|
+
#
|
|
204
|
+
# ## Failure direction
|
|
205
|
+
#
|
|
206
|
+
# Every path here degrades to *not attached*, never to *broken*. A missing token,
|
|
207
|
+
# a Rack that cannot be loaded, a wrap that raises: the message goes to stderr
|
|
208
|
+
# and the application runs exactly as it would have. This code executes before
|
|
209
|
+
# the first line of somebody's site, and §3 rule 7's fail-closed rule is about
|
|
210
|
+
# writes, credentials and budgets — not about refusing to let a homepage render.
|
|
211
|
+
#
|
|
212
|
+
# The one failure it cannot soften is a `LoadError` on `-rfindxpand/auto`
|
|
213
|
+
# itself: Ruby exits before this file runs. That is why the README's first step
|
|
214
|
+
# is the Gemfile and the second is the `RUBYOPT`.
|
|
215
|
+
#
|
|
216
|
+
# Frozen string literals: on.
|
|
217
|
+
|
|
218
|
+
require 'findxpand'
|
|
219
|
+
|
|
220
|
+
module Findxpand
|
|
221
|
+
module Auto
|
|
222
|
+
# The builder classes we prepend on sight, by name, without requiring any of
|
|
223
|
+
# them.
|
|
224
|
+
#
|
|
225
|
+
# Names rather than constants: this file loads before the application's first
|
|
226
|
+
# line, and `Puma::Rack::Builder` is a `NameError` at that point on every
|
|
227
|
+
# stack including Puma's own. Resolved one segment at a time through
|
|
228
|
+
# `const_defined?` so that a missing namespace is a `nil` rather than an
|
|
229
|
+
# exception, and so that nothing here can force a `require` of a gem the
|
|
230
|
+
# customer did not ask for.
|
|
231
|
+
#
|
|
232
|
+
# This list is a fast path and not the mechanism. Anything defined after us —
|
|
233
|
+
# which is every server's own copy, since the server loads after `RUBYOPT` —
|
|
234
|
+
# is caught by shape in `watch_for_builders`, so a name missing from here
|
|
235
|
+
# costs nothing but the microsecond the watcher takes to recognise it. A name
|
|
236
|
+
# in here that no gem defines costs nothing either: `resolve` answers `nil`
|
|
237
|
+
# and the row never appears in `builders`. `Rackup::Builder` is in the list on
|
|
238
|
+
# that basis — Rack 3 moved `Rack::Server` and `Rack::Handler` into the
|
|
239
|
+
# `rackup` gem, and whether `Builder` went with them could not be checked from
|
|
240
|
+
# this machine.
|
|
241
|
+
BUILDER_CLASSES = %w[
|
|
242
|
+
Rack::Builder
|
|
243
|
+
Rackup::Builder
|
|
244
|
+
Puma::Rack::Builder
|
|
245
|
+
].freeze
|
|
246
|
+
|
|
247
|
+
# The three methods that make a class a Rack builder.
|
|
248
|
+
#
|
|
249
|
+
# `to_app` alone is not enough — `Rails::Application` and several test
|
|
250
|
+
# harnesses answer to it — and prepending to something that is not a builder
|
|
251
|
+
# would put our `super` call in front of a method that means something else.
|
|
252
|
+
# All three together are the `config.ru` DSL and nothing else has them.
|
|
253
|
+
BUILDER_METHODS = %i[to_app use run].freeze
|
|
254
|
+
|
|
255
|
+
# Guards against a second `-r`, a re-require under a different path, or a
|
|
256
|
+
# `require 'findxpand/auto'` in an initialiser on top of the `RUBYOPT`. Two
|
|
257
|
+
# prepends of the same module are harmless — Ruby ignores the second — but
|
|
258
|
+
# the warning would be printed twice and the answer to `installed?` would
|
|
259
|
+
# stop meaning anything.
|
|
260
|
+
@installed = false
|
|
261
|
+
# name => true, for every class we actually prepended. The keys are the
|
|
262
|
+
# report; the Hash is the idempotence check.
|
|
263
|
+
@attached = {}
|
|
264
|
+
# Applications built through a hook we placed. **The only evidence the hook
|
|
265
|
+
# ran**, as distinct from `@installed`, which says only that it was placed.
|
|
266
|
+
@built = 0
|
|
267
|
+
@built_lock = Mutex.new
|
|
268
|
+
@watch = nil
|
|
269
|
+
@watch_error = nil
|
|
270
|
+
|
|
271
|
+
# Why nothing is attached in this process, or `attached`.
|
|
272
|
+
#
|
|
273
|
+
# `pending` `install` has not run — the file was required and nothing else
|
|
274
|
+
# `no-token` FINDXPAND_TOKEN was empty, so we declined to attach
|
|
275
|
+
# `disabled` FINDXPAND_ENABLED was off. Somebody meant this
|
|
276
|
+
# `no-builder` no builder was loaded and none can be watched for
|
|
277
|
+
# `watching` hooks placed; no application has been built through one yet
|
|
278
|
+
# `attached` an application **was** built through a hook we placed
|
|
279
|
+
# `failed` an exception during install; the application runs unchanged
|
|
280
|
+
#
|
|
281
|
+
# Six causes behind one boolean, which is the change
|
|
282
|
+
# `middleware/php/src/Auto.php:135-155` made after its Laravel provider read
|
|
283
|
+
# `attached() == false` as one particular cause and announced it on every
|
|
284
|
+
# `php artisan` command. `attached` here is a fact about the *hook* — an
|
|
285
|
+
# application came through it — and not a claim that a middleware resulted;
|
|
286
|
+
# `attach_problem` answers that, from `Middleware.constructed`.
|
|
287
|
+
@state = 'pending'
|
|
288
|
+
|
|
289
|
+
class << self
|
|
290
|
+
attr_reader :installed, :built, :state, :watch_error
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# For the suite only. Does not un-prepend: a module prepended to a class is
|
|
294
|
+
# there for the life of the process, which is why the tests define a fresh
|
|
295
|
+
# class per case rather than reusing one.
|
|
296
|
+
def self.reset!
|
|
297
|
+
@installed = false
|
|
298
|
+
@attached = {}
|
|
299
|
+
@built = 0
|
|
300
|
+
@state = 'pending'
|
|
301
|
+
@watch_error = nil
|
|
302
|
+
@watch&.disable
|
|
303
|
+
@watch = nil
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Delegated rather than duplicated: two spellings of the `findxpand:` prefix
|
|
307
|
+
# is the §20.1 rule 2 shape at its smallest, and the prefix is what a
|
|
308
|
+
# customer greps their boot log for.
|
|
309
|
+
def self.warn_stderr(message)
|
|
310
|
+
Findxpand.warn_stderr(message)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Wrap one built application, or hand it back with a reason.
|
|
314
|
+
#
|
|
315
|
+
# Never raises. This is called from inside somebody else's boot, and an
|
|
316
|
+
# exception here is a site that does not start.
|
|
317
|
+
def self.wrap(app)
|
|
318
|
+
return app if app.nil?
|
|
319
|
+
# `to_app` on a builder whose `run` was already our middleware. Harmless to
|
|
320
|
+
# wrap twice — `Middleware::SEEN_KEY` makes the inner one a no-op — but
|
|
321
|
+
# pointless, and one fewer object per request path is one fewer thing to
|
|
322
|
+
# explain in a stack trace.
|
|
323
|
+
return app if app.is_a?(Findxpand::Middleware)
|
|
324
|
+
# **Whatever this is, it is not a Rack application.** The safety net under
|
|
325
|
+
# the shape test: `builder_shape?` recognises a builder by three method
|
|
326
|
+
# names, and a class somewhere in a large application may carry all three
|
|
327
|
+
# and mean something else entirely by them. Wrapping *that* object's return
|
|
328
|
+
# value would hand its caller a `Findxpand::Middleware` where it expected
|
|
329
|
+
# its own type, which is a broken application rather than an unattached
|
|
330
|
+
# one — the direction this file is not allowed to fail in. A Rack
|
|
331
|
+
# application is exactly a thing that answers `call`, so that is the test.
|
|
332
|
+
return app unless app.respond_to?(:call)
|
|
333
|
+
|
|
334
|
+
settings = Findxpand.options_from_env
|
|
335
|
+
return app if settings[:token].empty?
|
|
336
|
+
return app unless settings[:enabled]
|
|
337
|
+
|
|
338
|
+
# Constructed with no options **on purpose**. `Middleware#initialize`
|
|
339
|
+
# resolves every option from this same environment on every construction
|
|
340
|
+
# path, so passing them here as well would be a second reading of one fact
|
|
341
|
+
# — the §20.1 rule 2 shape, and the exact shape that produced the defect
|
|
342
|
+
# this gem shipped: the `RUBYOPT` path read `FINDXPAND_CACHE_FILE` and the
|
|
343
|
+
# other two paths did not. What is read here is only the question *whether
|
|
344
|
+
# to attach at all*, which the constructor does not answer.
|
|
345
|
+
Findxpand::Middleware.new(app)
|
|
346
|
+
rescue StandardError => e
|
|
347
|
+
warn_stderr("could not attach (#{e.class}: #{e.message}); your application is running " \
|
|
348
|
+
'unchanged.')
|
|
349
|
+
app
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# Prepended to every builder we find, which is the whole mechanism.
|
|
353
|
+
#
|
|
354
|
+
# `super` first, so the application is built exactly as it would have been
|
|
355
|
+
# and every `use` in the file is already inside what we are handed.
|
|
356
|
+
module Builder
|
|
357
|
+
def to_app
|
|
358
|
+
built = super
|
|
359
|
+
Findxpand::Auto.built!
|
|
360
|
+
Findxpand::Auto.wrap(built)
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# One application was built through a hook we placed.
|
|
365
|
+
#
|
|
366
|
+
# Counted **before** `wrap` decides anything, because the two questions are
|
|
367
|
+
# different and were being answered by one number: "did the hook run" is
|
|
368
|
+
# this, and "did a middleware result" is `Middleware.constructed`. A hook
|
|
369
|
+
# that ran and declined (no token) reports differently from a hook that never
|
|
370
|
+
# ran at all, and the second is the Puma defect.
|
|
371
|
+
def self.built!
|
|
372
|
+
@built_lock.synchronize do
|
|
373
|
+
@built += 1
|
|
374
|
+
@state = 'attached'
|
|
375
|
+
end
|
|
376
|
+
# Disarmed here. An application has been built through us, so the class
|
|
377
|
+
# that builds applications in this process is one we are already on, and a
|
|
378
|
+
# watcher that goes on watching is a callback on every class body for the
|
|
379
|
+
# life of the process buying nothing.
|
|
380
|
+
#
|
|
381
|
+
# The trade, stated: if some *inner* builder builds first — a `map` block,
|
|
382
|
+
# a Sinatra sub-application — we disarm before the server's own outer
|
|
383
|
+
# builder is defined, and we are then attached but not outermost. That is a
|
|
384
|
+
# degradation (a `Rack::Deflater` outside us can hand us gzip, which we
|
|
385
|
+
# count as `skip-encoded` and report as degraded) rather than a silence,
|
|
386
|
+
# and `builders` names exactly what we got.
|
|
387
|
+
@watch&.disable
|
|
388
|
+
rescue StandardError
|
|
389
|
+
nil
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# Prepend to every named builder class that is already defined.
|
|
393
|
+
#
|
|
394
|
+
# Idempotent, and safe to call again later: the Railtie calls it a second
|
|
395
|
+
# time, because by the time Rails runs its initializers Rack is certainly
|
|
396
|
+
# loaded even if it was not when `RUBYOPT` ran. Returns the names now
|
|
397
|
+
# attached.
|
|
398
|
+
#
|
|
399
|
+
# `names` is a seam for the suite, not an option. The list it defaults to is
|
|
400
|
+
# a constant because a customer has no business changing it, and a test
|
|
401
|
+
# cannot add `Rack::Builder` to a process that has no Rack in it.
|
|
402
|
+
def self.attach_builders(names = BUILDER_CLASSES)
|
|
403
|
+
names.each do |name|
|
|
404
|
+
next if @attached.key?(name)
|
|
405
|
+
|
|
406
|
+
klass = resolve(name)
|
|
407
|
+
next if klass.nil?
|
|
408
|
+
|
|
409
|
+
attach_class(klass, name)
|
|
410
|
+
end
|
|
411
|
+
@attached.keys
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# Resolve `A::B::C` without raising and without requiring anything.
|
|
415
|
+
#
|
|
416
|
+
# `Object.const_defined?('Puma::Rack::Builder')` raises `NameError` when
|
|
417
|
+
# `Puma` is not defined rather than answering false, which on a non-Puma
|
|
418
|
+
# stack is every boot — so the path is walked a segment at a time. `false` as
|
|
419
|
+
# the second argument keeps the lookup off `Object`'s ancestors, so a
|
|
420
|
+
# `Builder` constant belonging to something else cannot be mistaken for this
|
|
421
|
+
# one.
|
|
422
|
+
def self.resolve(name)
|
|
423
|
+
name.split('::').reduce(Object) do |scope, segment|
|
|
424
|
+
return nil unless scope.is_a?(Module) && scope.const_defined?(segment, false)
|
|
425
|
+
|
|
426
|
+
scope.const_get(segment, false)
|
|
427
|
+
end
|
|
428
|
+
rescue StandardError
|
|
429
|
+
nil
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
# Prepend to one class, once.
|
|
433
|
+
#
|
|
434
|
+
# The idempotence check is `ancestors.include?`, not our own bookkeeping: a
|
|
435
|
+
# class reopened later, a subclass of one we already hold, and a second call
|
|
436
|
+
# from the Railtie all arrive here, and the ancestor chain is the fact while
|
|
437
|
+
# a Hash of names is only our record of it.
|
|
438
|
+
def self.attach_class(klass, name = nil)
|
|
439
|
+
return false unless klass.is_a?(Module)
|
|
440
|
+
return false if klass.ancestors.include?(Builder)
|
|
441
|
+
|
|
442
|
+
klass.prepend(Builder)
|
|
443
|
+
@attached[name || klass.name || klass.to_s] = true
|
|
444
|
+
true
|
|
445
|
+
rescue StandardError
|
|
446
|
+
# A frozen class, or a Module that refuses a prepend. Not a reason to stop
|
|
447
|
+
# a boot, and the name simply does not appear in `builders`.
|
|
448
|
+
false
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
# Is this a Rack builder, whatever it is called?
|
|
452
|
+
#
|
|
453
|
+
# Asked of every class body that closes after we install. See
|
|
454
|
+
# `BUILDER_METHODS` for why all three are required.
|
|
455
|
+
def self.builder_shape?(klass)
|
|
456
|
+
return false unless klass.is_a?(Module)
|
|
457
|
+
|
|
458
|
+
BUILDER_METHODS.all? { |method| klass.method_defined?(method) }
|
|
459
|
+
rescue StandardError
|
|
460
|
+
false
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
# Watch for a builder class defined after us — which is every server's own.
|
|
464
|
+
#
|
|
465
|
+
# `:end` rather than `:class`, so the body has finished and the methods we
|
|
466
|
+
# test for exist. (`prepend` would work at `:class` too, since `super` is
|
|
467
|
+
# resolved at call time, but then the shape test would answer false for every
|
|
468
|
+
# class in the process and this would be a watcher that never fires.)
|
|
469
|
+
#
|
|
470
|
+
# Returns whether the watch is armed. Never raises: a Ruby without
|
|
471
|
+
# `TracePoint`, or one that refuses to enable it, degrades to the by-name
|
|
472
|
+
# half and says so on stderr from `install`.
|
|
473
|
+
def self.watch_for_builders
|
|
474
|
+
return true unless @watch.nil?
|
|
475
|
+
return false unless defined?(::TracePoint)
|
|
476
|
+
|
|
477
|
+
watch = ::TracePoint.new(:end) do |point|
|
|
478
|
+
# Deliberately no Mutex. Taking one inside a trace callback deadlocks the
|
|
479
|
+
# first time a `require` runs while this file holds it — `Mutex` is not
|
|
480
|
+
# reentrant — and the work being protected is a `prepend` Ruby already
|
|
481
|
+
# makes idempotent plus one Hash write. `install` runs on the main thread
|
|
482
|
+
# before the application exists, so there is no second writer to race.
|
|
483
|
+
#
|
|
484
|
+
# Every line of it inside a `rescue`, because an exception raised in a
|
|
485
|
+
# trace handler propagates into whatever code happened to close a class —
|
|
486
|
+
# somebody else's `require`, at their boot. This file's one promise is
|
|
487
|
+
# that it degrades to *not attached* and never to *broken*, and a raise
|
|
488
|
+
# here is the one place that promise could be broken from.
|
|
489
|
+
begin
|
|
490
|
+
target = point.self
|
|
491
|
+
attach_class(target) if builder_shape?(target)
|
|
492
|
+
rescue StandardError
|
|
493
|
+
nil
|
|
494
|
+
end
|
|
495
|
+
end
|
|
496
|
+
watch.enable
|
|
497
|
+
@watch = watch
|
|
498
|
+
true
|
|
499
|
+
rescue StandardError, NotImplementedError => e
|
|
500
|
+
@watch = nil
|
|
501
|
+
@watch_error = "#{e.class}: #{e.message}"
|
|
502
|
+
false
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
# What this process observed about its own attach, for `/status`.
|
|
506
|
+
#
|
|
507
|
+
# Every value is a count or a name of something that happened. Nothing here
|
|
508
|
+
# is derived from configuration, and nothing reports health for a state it
|
|
509
|
+
# did not examine (§20.1 rule 3).
|
|
510
|
+
def self.report
|
|
511
|
+
{
|
|
512
|
+
'attach_state' => @state,
|
|
513
|
+
'builders' => @attached.keys.sort,
|
|
514
|
+
'built' => @built,
|
|
515
|
+
'watching' => !@watch.nil? && @watch.enabled?
|
|
516
|
+
}.tap do |report|
|
|
517
|
+
report['watch_error'] = @watch_error unless @watch_error.nil?
|
|
518
|
+
problem = attach_problem
|
|
519
|
+
report['attach_problem'] = problem unless problem.nil?
|
|
520
|
+
end
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
# The sentence for a `-rfindxpand/auto` that did not reach anything, or
|
|
524
|
+
# `nil` when there is nothing to report.
|
|
525
|
+
#
|
|
526
|
+
# `nil` is "we looked and there is nothing wrong", which is only sayable
|
|
527
|
+
# because both inputs are observations: an application was built through us,
|
|
528
|
+
# and a middleware exists. Neither can be true while the attach is a no-op.
|
|
529
|
+
def self.attach_problem
|
|
530
|
+
return nil unless @installed
|
|
531
|
+
|
|
532
|
+
if @built.zero?
|
|
533
|
+
return 'findxpand/auto ran and no application has been built through it. Hooks were ' \
|
|
534
|
+
"placed on #{@attached.keys.sort.inspect}; this server built its application " \
|
|
535
|
+
'some other way - it parses config.ru with its own builder class, or it was ' \
|
|
536
|
+
'handed an app object in code. If this endpoint is answering, the Rails ' \
|
|
537
|
+
'Railtie is what mounted us.'
|
|
538
|
+
end
|
|
539
|
+
if Findxpand::Middleware.constructed.zero?
|
|
540
|
+
return 'an application was built through findxpand/auto and no middleware was added ' \
|
|
541
|
+
'to it - FINDXPAND_TOKEN was empty, or FINDXPAND_ENABLED was off, at the ' \
|
|
542
|
+
'moment it was built.'
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
nil
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
# The whole of what a `-rfindxpand/auto` process does before its own first
|
|
549
|
+
# line.
|
|
550
|
+
def self.install
|
|
551
|
+
return false if @installed
|
|
552
|
+
|
|
553
|
+
if ENV['FINDXPAND_TOKEN'].to_s.empty?
|
|
554
|
+
@state = 'no-token'
|
|
555
|
+
warn_stderr('FINDXPAND_TOKEN is not set, so nothing was attached and this process ' \
|
|
556
|
+
'serves your pages unchanged.')
|
|
557
|
+
return false
|
|
558
|
+
end
|
|
559
|
+
unless Findxpand.flag('FINDXPAND_ENABLED', true)
|
|
560
|
+
# Deliberately silent. Somebody set this on purpose.
|
|
561
|
+
@state = 'disabled'
|
|
562
|
+
return false
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
@installed = true
|
|
566
|
+
load_rack
|
|
567
|
+
attach_builders
|
|
568
|
+
watching = watch_for_builders
|
|
569
|
+
@state = 'watching'
|
|
570
|
+
|
|
571
|
+
if @attached.empty? && !watching
|
|
572
|
+
# Nothing to prepend and no way to notice one appearing. This process
|
|
573
|
+
# cannot attach, and the only channel it has is this line.
|
|
574
|
+
@state = 'no-builder'
|
|
575
|
+
warn_stderr('no Rack builder class is loaded and this Ruby will not let us watch for ' \
|
|
576
|
+
"one (#{@watch_error}), so nothing was attached and this process serves " \
|
|
577
|
+
'your pages unchanged.')
|
|
578
|
+
elsif !watching
|
|
579
|
+
# The by-name half is live and the by-shape half is not. Said out loud,
|
|
580
|
+
# because this is precisely the configuration in which a server that
|
|
581
|
+
# vendors its own builder — Puma does — attaches nothing and warns
|
|
582
|
+
# nothing, which is the defect this watch exists to end.
|
|
583
|
+
warn_stderr("watching for a server's own builder is unavailable here " \
|
|
584
|
+
"(#{@watch_error}). Servers that parse config.ru with a vendored copy of " \
|
|
585
|
+
'Rack::Builder - Puma is one - will not be reached. Check ' \
|
|
586
|
+
'/__findxpand/status: `built: 0` means this happened.')
|
|
587
|
+
end
|
|
588
|
+
true
|
|
589
|
+
rescue StandardError => e
|
|
590
|
+
@state = 'failed'
|
|
591
|
+
warn_stderr("bootstrap skipped (#{e.class}: #{e.message}); running unchanged.")
|
|
592
|
+
false
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
# Rack, if this process has it. Not an error when it does not: the Railtie
|
|
596
|
+
# may still attach, the watch may still see a builder, and if nothing does,
|
|
597
|
+
# `/status` and stderr say so rather than implying health.
|
|
598
|
+
def self.load_rack
|
|
599
|
+
require 'rack'
|
|
600
|
+
true
|
|
601
|
+
rescue LoadError
|
|
602
|
+
false
|
|
603
|
+
end
|
|
604
|
+
end
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
Findxpand::Auto.install
|