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,214 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# `findxpand init | doctor | status` — three commands, and no network.
|
|
4
|
+
#
|
|
5
|
+
# **Nothing in this file makes an HTTP request, including to localhost.** The
|
|
6
|
+
# gem's one architectural promise is that it never calls out: Findxpand pushes to
|
|
7
|
+
# the customer's origin and the customer's origin never fetches from Findxpand,
|
|
8
|
+
# so that our uptime is never their uptime. A CLI that quietly curled something
|
|
9
|
+
# would be the first exception, and the exception is how the promise stops being
|
|
10
|
+
# true. `status` therefore reads the cache file this machine holds and prints the
|
|
11
|
+
# `curl` line for the endpoint rather than calling it — which is also the only
|
|
12
|
+
# honest thing it can do, since this process does not know which port the server
|
|
13
|
+
# is on.
|
|
14
|
+
#
|
|
15
|
+
# ## What `doctor` can and cannot tell you
|
|
16
|
+
#
|
|
17
|
+
# It runs in a *different process* from the server, so it cannot answer the only
|
|
18
|
+
# question that finally matters — "is the middleware in the request path" — and
|
|
19
|
+
# it says so rather than implying otherwise. Everything it checks is a
|
|
20
|
+
# precondition. `/__findxpand/status` is the thing that knows, and `doctor`
|
|
21
|
+
# prints the command for asking it. §20.1 rule 3: a tool whose job is to catch
|
|
22
|
+
# failure must not report health for something it never examined, and the honest
|
|
23
|
+
# form of that here is naming what was out of reach.
|
|
24
|
+
#
|
|
25
|
+
# Frozen string literals: on. Output goes through `puts`, never through `<<` on a
|
|
26
|
+
# literal.
|
|
27
|
+
|
|
28
|
+
require 'findxpand'
|
|
29
|
+
require 'json'
|
|
30
|
+
|
|
31
|
+
module Findxpand
|
|
32
|
+
module CLI
|
|
33
|
+
USAGE = <<~TEXT
|
|
34
|
+
usage: findxpand <command>
|
|
35
|
+
|
|
36
|
+
init print the lines to add to your app, ready to paste
|
|
37
|
+
doctor check this machine's preconditions, and say what it cannot check
|
|
38
|
+
status print the manifest this machine has cached
|
|
39
|
+
|
|
40
|
+
FINDXPAND_TOKEN must be set in the same environment as your server.
|
|
41
|
+
TEXT
|
|
42
|
+
|
|
43
|
+
def self.run(argv, out: $stdout, err: $stderr, env: ENV)
|
|
44
|
+
case argv.first
|
|
45
|
+
when 'init'
|
|
46
|
+
init(out, env)
|
|
47
|
+
when 'doctor'
|
|
48
|
+
doctor(out, env)
|
|
49
|
+
when 'status'
|
|
50
|
+
status(out, err, env)
|
|
51
|
+
when nil, '-h', '--help', 'help'
|
|
52
|
+
out.puts(USAGE)
|
|
53
|
+
0
|
|
54
|
+
when '-v', '--version'
|
|
55
|
+
out.puts(VERSION)
|
|
56
|
+
0
|
|
57
|
+
else
|
|
58
|
+
err.puts("findxpand: unknown command #{argv.first.inspect}")
|
|
59
|
+
err.puts(USAGE)
|
|
60
|
+
2
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.init(out, env)
|
|
65
|
+
token = env['FINDXPAND_TOKEN'].to_s.empty? ? '<we send you this>' : '<already set>'
|
|
66
|
+
out.puts('# 1. the gem.')
|
|
67
|
+
out.puts("# #{UNPUBLISHED_NOTE}") unless PUBLISHED_TO_REGISTRIES
|
|
68
|
+
# `Findxpand.` spelled out. Constants resolve up the lexical scope and
|
|
69
|
+
# methods do not, so a bare `install_command` here is a NameError rather
|
|
70
|
+
# than the enclosing module's method.
|
|
71
|
+
out.puts("# #{Findxpand.install_command}")
|
|
72
|
+
out.puts
|
|
73
|
+
out.puts('# in your Gemfile, so that `bundle exec` can find it. This line is not')
|
|
74
|
+
out.puts('# optional: without it the RUBYOPT below raises LoadError and your server')
|
|
75
|
+
out.puts('# does not start at all.')
|
|
76
|
+
out.puts("gem 'findxpand'")
|
|
77
|
+
out.puts
|
|
78
|
+
out.puts('# 2. the attach. One prefix on the command you already run:')
|
|
79
|
+
out.puts("#{ATTACH} bundle exec puma")
|
|
80
|
+
out.puts
|
|
81
|
+
out.puts('# 3. the environment your server runs in:')
|
|
82
|
+
out.puts("FINDXPAND_TOKEN=#{token}")
|
|
83
|
+
out.puts('FINDXPAND_CACHE_FILE=/var/lib/findxpand/manifest.json')
|
|
84
|
+
out.puts
|
|
85
|
+
out.puts('# The cache path must survive a restart, and must be shared by every worker')
|
|
86
|
+
out.puts('# if your server forks (Puma in cluster mode, Unicorn, Passenger). Without')
|
|
87
|
+
out.puts('# it a restart drops every rule until the next push, and a push reaches one')
|
|
88
|
+
out.puts('# worker out of however many you run.')
|
|
89
|
+
out.puts
|
|
90
|
+
# Printed commented-out, and printed at all because the README listed the
|
|
91
|
+
# other five variables and omitted this one until 2 Sep 2026. It controls
|
|
92
|
+
# the whole Accept-Encoding takeover this gem is built around, and a
|
|
93
|
+
# customer behind an intermediary that has already taken over content
|
|
94
|
+
# negotiation - the one supported reason to turn it off, named in
|
|
95
|
+
# `middleware.rb` - had no way to learn the variable existed. What they
|
|
96
|
+
# reach for instead is FINDXPAND_ENABLED, which turns off every rewrite on
|
|
97
|
+
# the site.
|
|
98
|
+
out.puts('# Only if something in front of your app has already taken over content')
|
|
99
|
+
out.puts('# negotiation. Off means a page with a rule is served uncompressed; it')
|
|
100
|
+
out.puts('# does not change what is rewritten.')
|
|
101
|
+
out.puts('# FINDXPAND_RECOMPRESS=off')
|
|
102
|
+
0
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def self.doctor(out, env)
|
|
106
|
+
results = []
|
|
107
|
+
results << check('ruby >= 3.0', RUBY_VERSION.split('.').first.to_i >= 3,
|
|
108
|
+
"this is Ruby #{RUBY_VERSION}")
|
|
109
|
+
results << check('FINDXPAND_TOKEN is set', !env['FINDXPAND_TOKEN'].to_s.empty?,
|
|
110
|
+
'without it nothing attaches and the admin endpoints 401 everybody')
|
|
111
|
+
# Worded as "a Rack builder" rather than "Rack::Builder", because the attach
|
|
112
|
+
# stopped being one class on 2 Sep 2026: it prepends every loaded builder by
|
|
113
|
+
# name and watches for the one a server defines for itself (Puma vendors
|
|
114
|
+
# one). Rack being loadable is still the honest precondition to check here -
|
|
115
|
+
# it is the one this process can see - and it is no longer the whole
|
|
116
|
+
# mechanism, so it must not be described as if it were.
|
|
117
|
+
results << check('rack is loadable', rack_loadable?,
|
|
118
|
+
'the zero-code attach prepends a Rack builder; without Rack in ' \
|
|
119
|
+
'this process there is none to find here')
|
|
120
|
+
results << check('RUBYOPT carries findxpand/auto',
|
|
121
|
+
env['RUBYOPT'].to_s.include?('findxpand/auto'),
|
|
122
|
+
'checked for THIS process only - the one that matters is your server\'s')
|
|
123
|
+
results << cache_check(env)
|
|
124
|
+
|
|
125
|
+
out.puts('findxpand doctor')
|
|
126
|
+
out.puts
|
|
127
|
+
results.each { |line, _ok| out.puts(" #{line}") }
|
|
128
|
+
out.puts
|
|
129
|
+
out.puts('This process is not your server, so nothing above can tell you whether the')
|
|
130
|
+
out.puts('middleware is in the request path. One thing can:')
|
|
131
|
+
out.puts
|
|
132
|
+
out.puts(' curl -H "Authorization: Bearer $FINDXPAND_TOKEN" \\')
|
|
133
|
+
out.puts(" http://localhost:3000#{Admin::STATUS_PATH}")
|
|
134
|
+
out.puts
|
|
135
|
+
out.puts('Read `degraded`, `degraded_reason`, `persisted` and `attach`. A process')
|
|
136
|
+
out.puts('that has been up a while with `requests_seen: 0` is attached somewhere')
|
|
137
|
+
out.puts('requests do not go, and reports itself degraded rather than healthy;')
|
|
138
|
+
out.puts('`persisted: false` means a push was taken and could not be written, so a')
|
|
139
|
+
out.puts('restart drops it; `attach.built: 0` means this process placed its hook and')
|
|
140
|
+
out.puts('the hook never ran - the server built its application some other way.')
|
|
141
|
+
results.all? { |_line, ok| ok } ? 0 : 1
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def self.status(out, err, env)
|
|
145
|
+
path = env['FINDXPAND_CACHE_FILE'].to_s
|
|
146
|
+
if path.empty?
|
|
147
|
+
err.puts('findxpand: FINDXPAND_CACHE_FILE is not set, so there is no manifest on ' \
|
|
148
|
+
'this machine to read.')
|
|
149
|
+
return 1
|
|
150
|
+
end
|
|
151
|
+
unless File.exist?(path)
|
|
152
|
+
# Not an error in the sense of a bug: a fresh install has been pushed
|
|
153
|
+
# nothing yet. Said in words rather than by printing an empty table,
|
|
154
|
+
# which would read as "no rules" when it means "no file".
|
|
155
|
+
err.puts("findxpand: no manifest cached at #{path} yet - nothing has been pushed " \
|
|
156
|
+
'to this machine, or the server writes it somewhere else.')
|
|
157
|
+
return 1
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
begin
|
|
161
|
+
manifest = JSON.parse(File.read(path, encoding: 'UTF-8'))
|
|
162
|
+
rescue StandardError => e
|
|
163
|
+
err.puts("findxpand: #{path} is not readable as a manifest (#{e.class}).")
|
|
164
|
+
return 1
|
|
165
|
+
end
|
|
166
|
+
unless Store.manifest?(manifest)
|
|
167
|
+
err.puts("findxpand: #{path} parsed but is not a manifest - expected {version, pages}.")
|
|
168
|
+
return 1
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
pages = manifest['pages']
|
|
172
|
+
redirects = manifest['redirects'].is_a?(Array) ? manifest['redirects'].size : 0
|
|
173
|
+
out.puts("version #{manifest['version']}")
|
|
174
|
+
out.puts("pages #{pages.size}")
|
|
175
|
+
out.puts("redirects #{redirects}")
|
|
176
|
+
out.puts("written #{File.mtime(path)}")
|
|
177
|
+
out.puts
|
|
178
|
+
pages.keys.sort.each do |key|
|
|
179
|
+
fields = pages[key].is_a?(Hash) ? pages[key].keys.sort.join(', ') : ''
|
|
180
|
+
out.puts(" #{key} #{fields}")
|
|
181
|
+
end
|
|
182
|
+
out.puts
|
|
183
|
+
out.puts('This is the file on disk. Whether the running process is serving it is a')
|
|
184
|
+
out.puts("different question, and only #{Admin::STATUS_PATH} answers it.")
|
|
185
|
+
0
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def self.check(name, ok, detail)
|
|
189
|
+
line = ok ? "ok #{name}" : "FAIL #{name} - #{detail}"
|
|
190
|
+
[line, ok]
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def self.cache_check(env)
|
|
194
|
+
path = env['FINDXPAND_CACHE_FILE'].to_s
|
|
195
|
+
if path.empty?
|
|
196
|
+
return check('FINDXPAND_CACHE_FILE is set', false,
|
|
197
|
+
'without it a restart drops every rule until the next push, and on a ' \
|
|
198
|
+
'forking server a push reaches one worker')
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
directory = File.dirname(path)
|
|
202
|
+
writable = File.directory?(directory) && File.writable?(directory)
|
|
203
|
+
check("#{directory} is writable", writable,
|
|
204
|
+
'the manifest cannot be persisted there, so a restart drops every rule')
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def self.rack_loadable?
|
|
208
|
+
require 'rack'
|
|
209
|
+
defined?(::Rack::Builder) ? true : false
|
|
210
|
+
rescue LoadError
|
|
211
|
+
false
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Taking the response uncompressed, and giving it back compressed.
|
|
4
|
+
#
|
|
5
|
+
# ## The bug this exists to close
|
|
6
|
+
#
|
|
7
|
+
# The install screen told customers to mount the middleware outermost. In Rack
|
|
8
|
+
# that is `insert 0` or the first `use` in `config.ru`, and it is the only place
|
|
9
|
+
# a wrapper of this kind can go — and on its own it is **wrong**. `use
|
|
10
|
+
# Rack::Deflater` inside our position means the body we are handed has already
|
|
11
|
+
# been gzipped, so the rewrite declines, marks the response `skip-encoded` and
|
|
12
|
+
# serves the customer's original title. The same measurement was taken against
|
|
13
|
+
# Django's `GZipMiddleware` and Express's `compression` on 31 Aug 2026:
|
|
14
|
+
#
|
|
15
|
+
# marker skip-encoded
|
|
16
|
+
# title served <title>Original</title>
|
|
17
|
+
# /status healthy
|
|
18
|
+
#
|
|
19
|
+
# Nothing failed. That is the whole problem: the customer deployed, the screen
|
|
20
|
+
# said connected, `/status` said fine, and the fix never landed. There is no
|
|
21
|
+
# mount position that fixes it either — inside the compression we are no longer
|
|
22
|
+
# outermost and miss half the responses, and in Express mounting the other way
|
|
23
|
+
# round put 2069 bytes of plain HTML on the wire under `content-encoding: gzip`.
|
|
24
|
+
# The instruction had no right answer.
|
|
25
|
+
#
|
|
26
|
+
# ## What replaces it
|
|
27
|
+
#
|
|
28
|
+
# The wrapper stops asking to be placed correctly and takes control of the
|
|
29
|
+
# negotiation instead. For a path the manifest names, and only for those:
|
|
30
|
+
#
|
|
31
|
+
# inbound env['HTTP_ACCEPT_ENCODING'] is replaced with `identity`, so the
|
|
32
|
+
# application and every middleware it wraps produce real HTML
|
|
33
|
+
# outbound the rewritten page is compressed here, with the coding the
|
|
34
|
+
# client actually asked for
|
|
35
|
+
#
|
|
36
|
+
# `Rack::Deflater` reads `env['HTTP_ACCEPT_ENCODING']` *after* calling the app,
|
|
37
|
+
# so overwriting it on the way down is enough to make it stand aside; and a
|
|
38
|
+
# `Rack::Deflater` mounted *outside* us stands aside too, because it declines a
|
|
39
|
+
# response that already carries a `content-encoding`. This is the sidecar's
|
|
40
|
+
# technique — `proxy.ts` asks the origin for `identity` for the identical reason
|
|
41
|
+
# — applied inside the process.
|
|
42
|
+
#
|
|
43
|
+
# A path with no rule keeps its own `Accept-Encoding` untouched and is compressed
|
|
44
|
+
# by the application exactly as before, which is what keeps the cost of turning
|
|
45
|
+
# this on at zero.
|
|
46
|
+
#
|
|
47
|
+
# ## Only gzip
|
|
48
|
+
#
|
|
49
|
+
# The standard library has zlib and no brotli, and this gem has no dependencies
|
|
50
|
+
# on purpose — one that drags in a build-time C extension is one that can break
|
|
51
|
+
# somebody's deploy. Every client that offers `br` offers `gzip` alongside it, so
|
|
52
|
+
# the practical cost is a few percent on the wire for the handful of pages we
|
|
53
|
+
# rewrite. A client that offers brotli *alone* gets identity, which is correct
|
|
54
|
+
# and merely larger. This mirrors the Python package exactly; the Node one adds
|
|
55
|
+
# brotli because Node ships a brotli in its standard library.
|
|
56
|
+
#
|
|
57
|
+
# Frozen string literals: on. The compressed body is built in a `StringIO`, never
|
|
58
|
+
# by appending to a literal.
|
|
59
|
+
|
|
60
|
+
require 'zlib'
|
|
61
|
+
require 'stringio'
|
|
62
|
+
|
|
63
|
+
module Findxpand
|
|
64
|
+
module Encoding
|
|
65
|
+
# Below this a compressed body is usually the larger one. Matches the Node
|
|
66
|
+
# and Python sides' `MIN_COMPRESS_BYTES` so the three packages do not
|
|
67
|
+
# disagree about whether a given page came back compressed.
|
|
68
|
+
MIN_COMPRESS_BYTES = 1024
|
|
69
|
+
|
|
70
|
+
# What we can actually produce. An Array so the negotiation below reads as a
|
|
71
|
+
# preference order rather than a special case.
|
|
72
|
+
SUPPORTED = %w[gzip].freeze
|
|
73
|
+
|
|
74
|
+
# The best coding we can produce from what the client offered, or `''`.
|
|
75
|
+
#
|
|
76
|
+
# `;q=0` is read, because it is a refusal rather than an offer and sending a
|
|
77
|
+
# coding a client has explicitly declined is a blank page. Anything else in
|
|
78
|
+
# the q-value grammar is ignored: the client sent a list of what it accepts
|
|
79
|
+
# and picking a supported entry out of it is the entire job here.
|
|
80
|
+
def self.negotiate(accept)
|
|
81
|
+
offered = {}
|
|
82
|
+
accept.to_s.downcase.split(',').each do |part|
|
|
83
|
+
token, _, params = part.partition(';')
|
|
84
|
+
name = token.strip
|
|
85
|
+
next if name.empty?
|
|
86
|
+
|
|
87
|
+
refused = params.split(';').any? { |piece| piece.strip.start_with?('q=') && q(piece).zero? }
|
|
88
|
+
offered[name] = true unless refused
|
|
89
|
+
end
|
|
90
|
+
SUPPORTED.find { |coding| offered[coding] } || ''
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# A q-value, or 1.0 when it is not a number.
|
|
94
|
+
#
|
|
95
|
+
# `Float()` and not `to_f` for `signature.rb`'s reason: `'q=nonsense'.to_f`
|
|
96
|
+
# is `0.0`, which reads as a *refusal* — so a malformed parameter would
|
|
97
|
+
# silently stop us compressing for that client rather than being ignored.
|
|
98
|
+
def self.q(piece)
|
|
99
|
+
Float(piece.strip[2..].to_s)
|
|
100
|
+
rescue ArgumentError, TypeError
|
|
101
|
+
1.0
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Compress, or `nil` when it should not or could not be done.
|
|
105
|
+
#
|
|
106
|
+
# `nil` rather than an exception, and `nil` for a body under the threshold as
|
|
107
|
+
# well as for a compressor that raised: the caller's answer to both is the
|
|
108
|
+
# same one, and it is the safe one — serve the bytes we were given.
|
|
109
|
+
def self.encode(body, coding)
|
|
110
|
+
body = body.to_s
|
|
111
|
+
return nil unless SUPPORTED.include?(coding)
|
|
112
|
+
# Bytes, not characters. A 900-character Arabic page is about 1.6 KB on
|
|
113
|
+
# the wire, and `length` would decline to compress it while `bytesize`
|
|
114
|
+
# measures what actually travels (§14).
|
|
115
|
+
return nil if body.bytesize < MIN_COMPRESS_BYTES
|
|
116
|
+
|
|
117
|
+
buffer = StringIO.new(String.new('', encoding: ::Encoding::BINARY))
|
|
118
|
+
writer = Zlib::GzipWriter.new(buffer)
|
|
119
|
+
# Set before the first write, which is the only time zlib accepts it. The
|
|
120
|
+
# same page must compress to the same bytes, or every response is a cache
|
|
121
|
+
# miss and an ETag nobody can match — the reason the Python side passes
|
|
122
|
+
# `mtime=0` to `gzip.compress`.
|
|
123
|
+
writer.mtime = 0
|
|
124
|
+
writer.write(body)
|
|
125
|
+
# `finish`, not `close`: it writes the gzip trailer and hands back the
|
|
126
|
+
# underlying IO without closing it, so `buffer.string` is unambiguously
|
|
127
|
+
# readable afterwards. `close` would close the StringIO as well, and
|
|
128
|
+
# whether that is safe is a detail of StringIO nobody should have to know.
|
|
129
|
+
writer.finish
|
|
130
|
+
buffer.string
|
|
131
|
+
rescue StandardError, Zlib::Error
|
|
132
|
+
# The application's bytes are still a correct answer. Every failure in this
|
|
133
|
+
# gem goes in the same direction: unmodified, never wrong.
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Did the application tell intermediaries not to re-encode this?
|
|
138
|
+
#
|
|
139
|
+
# Honoured, because the one thing worse than an uncompressed page is a
|
|
140
|
+
# compressed one that the application explicitly asked nobody to make.
|
|
141
|
+
def self.no_transform?(cache_control)
|
|
142
|
+
/no-transform/i.match?(cache_control.to_s)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# The `Vary` this response needs, or `''` when it already says it.
|
|
146
|
+
#
|
|
147
|
+
# The rule rather than the edit, because a Rack 2 header hash and a Rack 3
|
|
148
|
+
# one differ in case and a WSGI header list differs from both, and two copies
|
|
149
|
+
# of one rule is the shape §20.1 rule 2 is about. Every caller asks here and
|
|
150
|
+
# splices into its own structure.
|
|
151
|
+
#
|
|
152
|
+
# A shared cache that missed this serves gzip to the next client through it,
|
|
153
|
+
# including one that told us it cannot read gzip.
|
|
154
|
+
def self.vary_value(existing)
|
|
155
|
+
return '' if /accept-encoding/i.match?(existing.to_s)
|
|
156
|
+
|
|
157
|
+
existing.to_s.empty? ? 'Accept-Encoding' : "#{existing}, Accept-Encoding"
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|