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,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