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
data/lib/findxpand.rb
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Findxpand origin middleware — server-side SEO remediation in your own stack.
|
|
4
|
+
#
|
|
5
|
+
# RUBYOPT="-rfindxpand/auto" bundle exec puma
|
|
6
|
+
#
|
|
7
|
+
# One deploy. After that, approved fixes arrive as a manifest pushed to
|
|
8
|
+
# `/__findxpand/manifest` and no further release is needed — which is the reason
|
|
9
|
+
# this gem exists rather than a JSON file in the repository.
|
|
10
|
+
#
|
|
11
|
+
# The corrected bytes are produced on the server, before anything leaves the
|
|
12
|
+
# process. No JavaScript is involved anywhere, because AI crawlers do not run it
|
|
13
|
+
# (§3 rule 1) — a title injected on the client is not a remediation path.
|
|
14
|
+
#
|
|
15
|
+
# ## What is in here
|
|
16
|
+
#
|
|
17
|
+
# rewrite.rb the rewrite: pure, shared semantics, no I/O
|
|
18
|
+
# store.rb the manifest, the counters, the cache file, the verdict
|
|
19
|
+
# signature.rb HMAC over `timestamp.body`, constant time, 300s window
|
|
20
|
+
# admin.rb the two endpoints, and the only branches that fail closed
|
|
21
|
+
# middleware.rb the Rack middleware
|
|
22
|
+
# encoding.rb accept-encoding negotiation, and gzip on the way out
|
|
23
|
+
# auto.rb the zero-code attach
|
|
24
|
+
# railtie.rb Rails, at position 0
|
|
25
|
+
# cli.rb init / doctor / status
|
|
26
|
+
#
|
|
27
|
+
# Requiring this file is inert: it defines the classes and, if Rails is already
|
|
28
|
+
# loaded, registers the Railtie — which is what a Gemfile entry does and what a
|
|
29
|
+
# Rails developer expects a gem to do. It patches nothing. `findxpand/auto` is
|
|
30
|
+
# the file that attaches, and it has to be asked for by name.
|
|
31
|
+
#
|
|
32
|
+
# **Zero runtime dependencies, deliberately.** This runs inside somebody else's
|
|
33
|
+
# application, and a middleware that drags in a dependency tree is a middleware
|
|
34
|
+
# that can break their build. It is a Rack middleware that does not require Rack:
|
|
35
|
+
# nothing here needs more of Rack than the calling convention.
|
|
36
|
+
#
|
|
37
|
+
# Frozen string literals: on, in every file of this gem.
|
|
38
|
+
|
|
39
|
+
require 'findxpand/rewrite'
|
|
40
|
+
require 'findxpand/store'
|
|
41
|
+
require 'findxpand/signature'
|
|
42
|
+
require 'findxpand/encoding'
|
|
43
|
+
require 'findxpand/admin'
|
|
44
|
+
require 'findxpand/middleware'
|
|
45
|
+
|
|
46
|
+
module Findxpand
|
|
47
|
+
VERSION = '0.1.0'
|
|
48
|
+
|
|
49
|
+
# Not published to RubyGems, and nothing in this gem may claim otherwise.
|
|
50
|
+
#
|
|
51
|
+
# Mirrors `engine.fix.origin.PUBLISHED_TO_REGISTRIES`, which was written on
|
|
52
|
+
# 31 Aug 2026 after checking npm and PyPI and finding that three surfaces had
|
|
53
|
+
# been printing `npm install @findxpand/middleware` — a command that answers
|
|
54
|
+
# 404, and the first thing a customer would learn about us. There is no publish
|
|
55
|
+
# workflow in this tree for any of the three packages, so `gem install
|
|
56
|
+
# findxpand` fails the same way. The CLI and the README ask here.
|
|
57
|
+
PUBLISHED_TO_REGISTRIES = false
|
|
58
|
+
|
|
59
|
+
# Said once, wherever an install instruction appears, for as long as the gem is
|
|
60
|
+
# unpublished. Without it the command reads as a mistake.
|
|
61
|
+
UNPUBLISHED_NOTE =
|
|
62
|
+
'We send you this file - the gem is not on RubyGems yet, so there is nothing to ' \
|
|
63
|
+
'install by name. Keep it somewhere your build can reach; the version in the ' \
|
|
64
|
+
'filename is what a support conversation refers to.'
|
|
65
|
+
|
|
66
|
+
def self.install_command
|
|
67
|
+
return 'gem install findxpand' if PUBLISHED_TO_REGISTRIES
|
|
68
|
+
|
|
69
|
+
# Versioned deliberately: `gem install ./findxpand.gem` would install
|
|
70
|
+
# whatever happens to be in the client's download folder, and the version is
|
|
71
|
+
# the only thing tying a support conversation to a build.
|
|
72
|
+
"gem install ./findxpand-#{VERSION}.gem"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# How this gem attaches without the customer editing anything.
|
|
76
|
+
#
|
|
77
|
+
# Mirrors the `node` and `python` rows of `engine.fix.origin._ATTACH`, which
|
|
78
|
+
# has no `ruby` row yet — see the README.
|
|
79
|
+
ATTACH = 'RUBYOPT="-rfindxpand/auto"'
|
|
80
|
+
|
|
81
|
+
@store = nil
|
|
82
|
+
@store_lock = Mutex.new
|
|
83
|
+
@store_paths = nil
|
|
84
|
+
@path_conflict = nil
|
|
85
|
+
|
|
86
|
+
# The one manifest this process serves.
|
|
87
|
+
#
|
|
88
|
+
# Memoised, because Ruby has three attach points and two of them can be live at
|
|
89
|
+
# once: the builder prepend wraps what `config.ru` builds and the Railtie sits
|
|
90
|
+
# at position 0 inside it. Two `Store` objects would mean two
|
|
91
|
+
# manifests in one process — a push landing in the instance that happens to own
|
|
92
|
+
# `/__findxpand/manifest` and a rewrite reading the other one, which is a fix
|
|
93
|
+
# that reports as stored and never appears on a page. §20.1 rule 2: one
|
|
94
|
+
# implementation of "what are this process's rules", and as many readers as the
|
|
95
|
+
# stack needs.
|
|
96
|
+
#
|
|
97
|
+
# **What used to be written here was false, and it was the worst defect in the
|
|
98
|
+
# gem.** It said: "the first caller's paths win — that is the `RUBYOPT` attach
|
|
99
|
+
# in practice, since it runs before the application's first line". On Rails the
|
|
100
|
+
# first caller is *always the Railtie*, because Rails builds and instantiates
|
|
101
|
+
# its middleware stack during `require config/environment`, before `config.ru`
|
|
102
|
+
# reaches `to_app`. And until 2 Sep 2026 `Findxpand.options_from_env` had
|
|
103
|
+
# exactly one caller in the whole gem (`auto.rb`), so the Railtie and the
|
|
104
|
+
# documented hand mount both arrived here with `cache_file: nil` — whatever
|
|
105
|
+
# `FINDXPAND_CACHE_FILE` said. The memoised store was therefore the one that
|
|
106
|
+
# could not persist: `Store#write_cache` returned at its first line, a push
|
|
107
|
+
# answered `{"ok":true,"version":"…"}`, pages were rewritten, and `/status`
|
|
108
|
+
# reported health. The next restart reverted the site. Worse,
|
|
109
|
+
# `OriginMiddleware.apply` then read an empty deployed
|
|
110
|
+
# manifest and merged the next fix into `{}`, so every previously approved fix
|
|
111
|
+
# vanished with no error anywhere.
|
|
112
|
+
#
|
|
113
|
+
# The repair is in `Middleware#initialize`, which now resolves **every** option
|
|
114
|
+
# from the environment on every construction path, matching
|
|
115
|
+
# `middleware/php/src/Middleware.php:97-106` (`$options = array_merge(
|
|
116
|
+
# Auto::options(), $options )` — "defaults come from the environment, so a
|
|
117
|
+
# container binding with no arguments behaves like the bootstrap"). Once every
|
|
118
|
+
# caller resolves the same environment, every caller asks for the same paths
|
|
119
|
+
# and the memoisation has nothing left to lose.
|
|
120
|
+
#
|
|
121
|
+
# What is left here is the case that survives that: somebody passing an
|
|
122
|
+
# explicit path in code from a second mount point. First caller still wins — a
|
|
123
|
+
# second store would split the manifest, which is what this memoisation exists
|
|
124
|
+
# to prevent — but the discarded value is now *observed*, so it is reported
|
|
125
|
+
# rather than dropped on the floor (§20.1 rule 3). Once, on stderr, naming both.
|
|
126
|
+
def self.store(cache_file: nil, manifest_file: nil, clock: nil)
|
|
127
|
+
wanted = [cache_file, manifest_file]
|
|
128
|
+
@store_lock.synchronize do
|
|
129
|
+
if @store.nil?
|
|
130
|
+
@store_paths = wanted
|
|
131
|
+
@store = Store.new(cache_file: cache_file, manifest_file: manifest_file, clock: clock)
|
|
132
|
+
elsif wanted != @store_paths && @path_conflict.nil?
|
|
133
|
+
@path_conflict = "serving #{@store_paths.inspect}; #{wanted.inspect} was ignored"
|
|
134
|
+
warn_stderr('two mount points asked for different manifest paths - ' \
|
|
135
|
+
"#{@path_conflict}. One process serves one manifest: set " \
|
|
136
|
+
'FINDXPAND_CACHE_FILE and pass no paths in code.')
|
|
137
|
+
end
|
|
138
|
+
@store
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# For the suite only. A process that discarded its store mid-flight would lose
|
|
143
|
+
# every rule until the next push.
|
|
144
|
+
def self.reset_store!
|
|
145
|
+
@store_lock.synchronize do
|
|
146
|
+
@store = nil
|
|
147
|
+
@store_paths = nil
|
|
148
|
+
@path_conflict = nil
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# How this gem talks to stderr, in one place.
|
|
153
|
+
#
|
|
154
|
+
# `Auto.warn_stderr` delegates here rather than carrying its own copy: two
|
|
155
|
+
# spellings of the prefix is the §20.1 rule 2 shape at its smallest, and the
|
|
156
|
+
# prefix is what a customer greps their boot log for. `$stderr.write` rather
|
|
157
|
+
# than `Kernel#warn` because `warn` is silenced by `-W0` and by
|
|
158
|
+
# `Warning[:deprecated] = false`-style configuration in somebody else's
|
|
159
|
+
# application, and this is not a deprecation notice — it is the only channel an
|
|
160
|
+
# attach that did not attach has.
|
|
161
|
+
def self.warn_stderr(message)
|
|
162
|
+
$stderr.write("findxpand: #{message}\n")
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# What attached in this process, for `GET /__findxpand/status`.
|
|
166
|
+
#
|
|
167
|
+
# Additive keys. The engine spreads whatever the endpoint answers
|
|
168
|
+
# (`engine.fix.origin.status`, `{"ok": True, "installed": True, **payload}`), and
|
|
169
|
+
# `middleware/php/src/Store.php`'s `runtime`/`reach_reason` block is the
|
|
170
|
+
# precedent: a package reports the failures only its own runtime can have,
|
|
171
|
+
# named for what they mean rather than squeezed into a shared key.
|
|
172
|
+
#
|
|
173
|
+
# This answers the question the builder prepend could not. The total failure —
|
|
174
|
+
# `findxpand/auto` ran and nothing of ours was ever constructed — cannot be
|
|
175
|
+
# reported *here*, because there is then no middleware in the path to answer
|
|
176
|
+
# `/status` at all; that one goes to stderr from `Auto`. What is reported here
|
|
177
|
+
# is the partial case, which is the one a monitor can actually reach:
|
|
178
|
+
# `/status` answers because the Railtie mounted, `builders` names only
|
|
179
|
+
# `Rack::Builder`, and `built` is 0 — meaning the server parsed `config.ru`
|
|
180
|
+
# with its own vendored builder and the `RUBYOPT` attach never fired. That was
|
|
181
|
+
# the shipped behaviour under Puma until 2 Sep 2026 (`auto.rb`).
|
|
182
|
+
#
|
|
183
|
+
# `middlewares` is `Middleware.constructed`, and it is here rather than in
|
|
184
|
+
# `Auto` because it is true of the process however the middleware got there —
|
|
185
|
+
# the Railtie and a hand-written `use` move it too.
|
|
186
|
+
def self.attach_report
|
|
187
|
+
report = { 'auto' => defined?(Auto) ? true : false,
|
|
188
|
+
'middlewares' => Middleware.constructed }
|
|
189
|
+
report['store_conflict'] = @path_conflict unless @path_conflict.nil?
|
|
190
|
+
begin
|
|
191
|
+
report.merge!(Auto.report) if defined?(Auto)
|
|
192
|
+
rescue StandardError => e
|
|
193
|
+
# `/status` is the one endpoint that reports whether anything is wrong, and
|
|
194
|
+
# the engine's `verify` reads it. Raising here would take the health check
|
|
195
|
+
# down over the *reporting* of health, which is the tail wagging the dog —
|
|
196
|
+
# so the failure becomes a field instead of a 500. Named rather than
|
|
197
|
+
# swallowed: an absent `attach` block would read as "nothing to say", and
|
|
198
|
+
# this is "we could not look" (§20.1 rule 3).
|
|
199
|
+
report['attach_error'] = "#{e.class}: #{e.message}"
|
|
200
|
+
end
|
|
201
|
+
report
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# `Findxpand.middleware(app, **options)` — the explicit mount, for anybody who
|
|
205
|
+
# would rather see it in their `config.ru` than trust a hook.
|
|
206
|
+
#
|
|
207
|
+
# use Findxpand::Middleware
|
|
208
|
+
# # or
|
|
209
|
+
# run Findxpand.middleware(MyApp, token: ENV['FINDXPAND_TOKEN'])
|
|
210
|
+
#
|
|
211
|
+
# Mount it **first**, which in Rack is the first `use` in the file, and read
|
|
212
|
+
# `encoding.rb` before deciding that is enough: it is enough only because this
|
|
213
|
+
# middleware takes over the encoding negotiation on the paths it rewrites.
|
|
214
|
+
def self.middleware(app, **options)
|
|
215
|
+
Middleware.new(app, **options)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# `false`, `0`, `no` and `off` are off; anything else present is on.
|
|
219
|
+
#
|
|
220
|
+
# Written out because `ENV['X'] ? true : false` makes the string "false" true,
|
|
221
|
+
# and an operator who sets `FINDXPAND_ENABLED=false` to turn the gem off and
|
|
222
|
+
# finds it still running has no reason to suspect the flag.
|
|
223
|
+
def self.flag(name, fallback)
|
|
224
|
+
raw = ENV[name]
|
|
225
|
+
return fallback if raw.nil? || raw.empty?
|
|
226
|
+
|
|
227
|
+
!/\A(?:false|0|no|off)\z/i.match?(raw.strip)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Middleware options from the environment, in one place.
|
|
231
|
+
def self.options_from_env
|
|
232
|
+
settings = {
|
|
233
|
+
token: ENV['FINDXPAND_TOKEN'].to_s,
|
|
234
|
+
cache_file: env_path('FINDXPAND_CACHE_FILE'),
|
|
235
|
+
manifest_file: env_path('FINDXPAND_MANIFEST_FILE'),
|
|
236
|
+
enabled: flag('FINDXPAND_ENABLED', true),
|
|
237
|
+
require_signature: flag('FINDXPAND_REQUIRE_SIGNATURE', true),
|
|
238
|
+
recompress: flag('FINDXPAND_RECOMPRESS', true)
|
|
239
|
+
}
|
|
240
|
+
raw = ENV['FINDXPAND_MAX_BYTES'].to_s
|
|
241
|
+
settings[:max_bytes] = raw.to_i if /\A\d+\z/.match?(raw) && raw.to_i.positive?
|
|
242
|
+
settings
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def self.env_path(name)
|
|
246
|
+
value = ENV[name].to_s
|
|
247
|
+
value.empty? ? nil : value
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Registered here rather than in `auto.rb` because a Railtie is the shape a Rails
|
|
252
|
+
# developer expects a gem to have, and because `auto.rb` is loaded by `RUBYOPT`
|
|
253
|
+
# *before* Rails exists — at which point there is no `Rails::Railtie` to subclass.
|
|
254
|
+
# A Gemfile entry loads this file after Rails, which is when this line is true.
|
|
255
|
+
require 'findxpand/railtie' if defined?(::Rails::Railtie)
|
metadata
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: findxpand
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Nextoria
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-09-02 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: One deploy. After that, approved fixes arrive as a manifest pushed to
|
|
14
|
+
your own origin and no further release is needed. The corrected HTML is produced
|
|
15
|
+
on the server, so crawlers that do not run JavaScript still see it. Zero runtime
|
|
16
|
+
dependencies; it never makes an outbound request.
|
|
17
|
+
email:
|
|
18
|
+
executables:
|
|
19
|
+
- findxpand
|
|
20
|
+
extensions: []
|
|
21
|
+
extra_rdoc_files: []
|
|
22
|
+
files:
|
|
23
|
+
- README.md
|
|
24
|
+
- exe/findxpand
|
|
25
|
+
- findxpand.gemspec
|
|
26
|
+
- lib/findxpand.rb
|
|
27
|
+
- lib/findxpand/admin.rb
|
|
28
|
+
- lib/findxpand/auto.rb
|
|
29
|
+
- lib/findxpand/cli.rb
|
|
30
|
+
- lib/findxpand/encoding.rb
|
|
31
|
+
- lib/findxpand/middleware.rb
|
|
32
|
+
- lib/findxpand/railtie.rb
|
|
33
|
+
- lib/findxpand/rewrite.rb
|
|
34
|
+
- lib/findxpand/signature.rb
|
|
35
|
+
- lib/findxpand/store.rb
|
|
36
|
+
homepage: https://nextoria.ae/findxpand
|
|
37
|
+
licenses:
|
|
38
|
+
- MIT
|
|
39
|
+
metadata:
|
|
40
|
+
homepage_uri: https://nextoria.ae/findxpand
|
|
41
|
+
rubygems_mfa_required: 'true'
|
|
42
|
+
post_install_message:
|
|
43
|
+
rdoc_options: []
|
|
44
|
+
require_paths:
|
|
45
|
+
- lib
|
|
46
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
47
|
+
requirements:
|
|
48
|
+
- - ">="
|
|
49
|
+
- !ruby/object:Gem::Version
|
|
50
|
+
version: '3.0'
|
|
51
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
52
|
+
requirements:
|
|
53
|
+
- - ">="
|
|
54
|
+
- !ruby/object:Gem::Version
|
|
55
|
+
version: '0'
|
|
56
|
+
requirements: []
|
|
57
|
+
rubygems_version: 3.3.27
|
|
58
|
+
signing_key:
|
|
59
|
+
specification_version: 4
|
|
60
|
+
summary: Server-side SEO remediation at your origin, for Rack, Rails, Sinatra and
|
|
61
|
+
Hanami.
|
|
62
|
+
test_files: []
|