findxpand
v0.1.1
Published
Server-side SEO remediation at your origin. One deploy; fixes arrive as a pushed manifest.
Maintainers
Readme
findxpand
Server-side SEO remediation at your origin, for Express, Connect, Fastify's
http layer, Nest, or a bare http.createServer. One deploy; approved fixes
arrive afterwards as a pushed manifest, with no further release.
Start here
npx findxpand@latest init
pnpm dlx findxpand@latest init
yarn dlx findxpand@latest init
bunx --bun findxpand@latest initinit reads your project rather than asking you about it: the lockfile says
which package manager, package.json says which framework and runtime, and the
answer is the exact install for this repository — including the cases where
the answer is "not this route, use that one instead".
The whole install
NODE_OPTIONS="--require findxpand/auto" node server.jsNothing in your application changes. FINDXPAND_TOKEN in the same environment
is the only other thing needed, and findxpand init mints one for you.
Under Bun, --require does not exist and --preload is the equivalent:
bun --preload findxpand/auto server.jsUnverified. Bun was not installed on the machine this package shipped from,
so that line is reasoned from Bun's node:http compatibility and has not been
run by us. --preload also applies to one process, and a child bun does not
inherit it — if your start script shells out to another bun, the flag belongs
on that one, or preload = ["findxpand/auto"] goes in bunfig.toml.
Why not app.use()
Because there is no position in an Express stack that also uses compression
where mounting by hand does the right thing. Measured 31 Aug 2026:
| mount order | result |
|---|---|
| findxpand then compression | skip-encoded — the page is served unmodified, /status reports healthy, the fix silently never lands |
| compression then findxpand | 2069 bytes of plain HTML on the wire under content-encoding: gzip — undecodable |
Express applies each middleware's res.write patch in mount order, so whichever
one is mounted first ends up innermost and sees what the others produced.
There is no third position, so the step with no right answer is gone rather than
reworded.
/auto sidesteps it: it hooks Server.prototype.emit('request'), so it runs
outside the application entirely, and it owns the encoding negotiation — a page
the manifest names is fetched from your app as identity and compressed here.
A path the manifest does not name is not touched at all, so your own compression
still runs on the rest of the site exactly as before.
findxpand() is still exported for embedding, and is what /auto calls.
That is the only deploy. Approved fixes arrive afterwards as a manifest pushed
to /__findxpand/manifest, and no further release is needed.
Pick a cache path that survives a restart.
/var/tmpis fine on a VM and wrong on a container or a serverless platform, where it is discarded and every restart drops every rule until the next push. Use a mounted volume, and if the platform has no durable disk at all, say so — the manifest-in-the-repository mode exists for exactly that and does not need one.
What it reaches, and what it does not
The hook patches emit('request') on http.Server and https.Server, so it
reaches every framework that dispatches through one — Express, Connect, Koa,
Fastify, Nest, and a bare http.createServer, however the listener was
attached.
| Not reached | Why | Use instead |
|---|---|---|
| http2.createSecureServer | HTTP/2 sessions do not emit request on an http.Server | the sidecar |
| Deno | no node:http server in the path | the sidecar |
| Bun.serve | Bun's own server; it never constructs a node:http.Server, so there is no emit to patch | the sidecar |
| Express/Koa/Fastify/Nest on Bun | reached — Bun's node:http layer does emit request | bun --preload above (unverified) |
| Next.js, Nuxt | the App Router has no middleware chain to mount into and middleware.ts runs on the Edge runtime, never seeing the rendered body; Nitro builds to a serverless handler rather than a server you can wrap | the sidecar, when self-hosted |
| Vercel, Netlify, GitHub Pages, S3, Akamai NetStorage | the host serves the response, so there is no process to install into and nothing to put a container in front of | the Cloudflare edge route, or a pull request |
findxpand init refuses each of those by name, with the reason and with the
route that does work. A refusal with no alternative is not a refusal worth
printing.
findxpand doctor
The failure this package can have is invisible by construction: attached in a process the site's traffic does not reach, or holding a manifest whose keys match no path, it serves every page untouched and every other signal looks healthy.
npx findxpand doctor # http://127.0.0.1:3000
npx findxpand doctor --port 8080
npx findxpand doctor --url https://staging.exampleIt resolves the package in your project (not the copy running the command),
finds the token in --token, FINDXPAND_TOKEN or .env, reads
/__findxpand/status, and then does the thing a checklist cannot: it fetches
one path the manifest names and looks for the x-findxpand-origin-mw header on
the answer. That header is the only first-hand evidence that your site's traffic
passes through the middleware at all.
| exit | meaning |
|---|---|
| 0 | confirmed working — a rewrite was counted, or we saw the marker ourselves |
| 1 | confirmed broken — degraded, a page without the marker, a 401, or traffic arriving that matches no rule |
| 2 | could not examine — nothing answered, or it answers and has never been in the request path |
2 is never reported as health. A middleware that answers /status and has
never handled a request is not a healthy one; it is one nothing has looked at,
and that distinction is the whole reason this command exists.
findxpand status prints the same /status document as JSON, for a monitor.
Not a Node app? Run it as a sidecar
The same code, with a reverse proxy where your application would be. Nothing installs into your stack and it does not matter what your stack is — PHP, Rails, Java, .NET, Go, or static files behind nginx.
docker run -d --restart unless-stopped -p 8080:8080 \
-e FINDXPAND_TOKEN=... \
-e FINDXPAND_ORIGIN=http://your-app:3000 \
-e FINDXPAND_CACHE_FILE=/var/lib/findxpand/manifest.json \
-v findxpand:/var/lib/findxpand \
ghcr.io/nextoria-information-technology-llc/findxpand-proxy:0.1.1Then point whatever routes traffic to your app at the sidecar instead. That is the only infrastructure change.
Keep both halves of the cache — the -e and the -v. Without the volume the
manifest lives inside the container, and the first restart serves your old
titles back with nothing anywhere reporting a problem.
| Variable | Meaning |
|---|---|
| FINDXPAND_TOKEN | Required. The shared secret, as above. |
| FINDXPAND_ORIGIN | Required. Where your application listens. |
| FINDXPAND_PORT | What the sidecar listens on. Default 8080. |
| FINDXPAND_CACHE_FILE | Manifest cache. Put it on a mounted volume. |
| FINDXPAND_MANIFEST_FILE | A manifest committed to your repository, for the no-inbound-endpoint mode. |
| FINDXPAND_ENABLED | false leaves it running and stops it touching responses. |
| FINDXPAND_COMPRESS | Gzip on the way out. Default on. |
| FINDXPAND_OVERRIDE_HOST | Send the origin its own hostname instead of the visitor's. Default off, and leave it off unless virtual-host routing needs it — the origin builds canonical URLs from Host. |
| FINDXPAND_TIMEOUT_MS | How long to wait for the origin. Default 30000. |
| FINDXPAND_REQUIRE_SIGNATURE | Verify the signature on every push. Default on. |
The in-process package reads FINDXPAND_TOKEN, FINDXPAND_CACHE_FILE,
FINDXPAND_MANIFEST_FILE, FINDXPAND_MAX_BYTES, FINDXPAND_ENABLED and
FINDXPAND_REQUIRE_SIGNATURE from the same environment.
GET /__findxpand/health answers 200 with no token, for a container platform's
liveness probe. /status stays behind the token because it describes your SEO
configuration.
It asks your origin for uncompressed HTML (Accept-Encoding: identity) and
compresses on the way out itself. That is not optional: a rewrite cannot happen
on bytes that arrived gzipped.
Every visitor gets identical bytes. There is no user-agent branch anywhere in it. Serving crawlers something different is cloaking, and the whole point of a server-side rewrite is that it needs no such trick.
Options
For findxpand(options) when you are embedding it rather than using /auto.
| Option | Meaning |
|---|---|
| token | Shared secret. Required; the admin endpoints 401 without it. |
| cacheFile | Where a pushed manifest is persisted. Without it, every restart drops every rule until the next push. |
| manifestFile | A manifest committed to your repository, for the no-inbound-endpoint mode. Read at boot; a push overrides it. |
| enabled | false leaves the middleware mounted and stops it touching responses. |
| maxBytes | Largest HTML body to buffer. Default 2 MB; larger responses stream through untouched. |
| requireSignature | Verify the signature on every push. Default true. See below. |
What it does to a response
Only when the path has a rule, the status is 200, the content type is HTML, and nothing has already compressed it. Everything else is passed through and marked:
| x-findxpand-origin-mw | Meaning |
|---|---|
| 1 | rewritten |
| pass | a rule existed but changed nothing |
| error | the rewrite raised; your original bytes were served unchanged |
| skip-encoded | it arrived already compressed, so it was passed through untouched. With /auto this cannot happen — the hook asks your app for identity and re-encodes afterwards. Mounted by hand it means something compressed the response before the rewrite could see it, and there is no mount position that avoids that |
| skip-large | over maxBytes, served whole rather than truncated |
| redirect | served from the manifest's redirect table |
| status | a 404/410/451 rule answered before your app was reached |
On any exception, the original response is served. transform runs inside a
try/catch and the buffered bytes are sent untouched if it throws — that is
the reason for buffering at all. A bug of ours cannot take down a page.
A path with no rule never reaches any of this: next() is called before a
single response method is patched, so your JSON APIs, file downloads and
webhooks are never buffered.
setHeader and writeHead are both read
It does not matter which your application uses. Node does not surface headers
passed to writeHead(status, headers) through getHeader(), so until 31 Aug
2026 an app that set its content type that way had every page passed through
untouched — and /__findxpand/status reported degraded: false with
considered: 0, because nothing had been examined and nothing counted that.
Both forms now reach the same place. Object, flat array and list-of-pairs are
all accepted, as Node accepts them.
Endpoints
Both require Authorization: Bearer <token>, are no-store, and carry
x-robots-tag: noindex.
GET /__findxpand/status— version, rule count, age, health, and the manifestPOST /__findxpand/manifest— replace the rules; echoes the stored version
It tells you when it is doing nothing
/__findxpand/status reports counters, applied_age, degraded and
degraded_reason as well as the rule count. This is the failure worth
monitoring: attached where no rule matches, the middleware serves every page
untouched and every other signal looks healthy.
{
"degraded": true,
"degraded_reason": "12 response(s) arrived already compressed and could not be rewritten - the middleware is mounted after compression, and needs to be mounted before it",
"counters": { "considered": 12, "applied": 0, "skip_encoded": 12, "error": 0 }
}Alert on degraded. applied_age is seconds since a rewrite last landed, or
null if one never has.
That sentence is generated by the engine and is shared word for word by all four
implementations, so it still says "mount it before compression" — the advice
from before /auto existed. Read it as: something compressed these responses
before we saw them, and the answer is to attach with /auto, which asks your
app for identity and does the compressing itself. Rewording it here alone
would mean two health verdicts that disagree.
One counter is reported but is deliberately not part of the degraded
verdict. unmatched counts requests that reached the middleware and matched no
rule — requests rather than responses, so images and stylesheets are in it too.
It exists because considered: 0 on its own is unreadable: a manifest whose
keys match nothing looks exactly like a middleware nobody has sent a request to
yet, and that ambiguity is how a key-format disagreement between the sender and
this package stayed invisible for a fortnight. unmatched climbing while
considered stays at zero, on an install with rules loaded, means the keys are
not matching your paths. It is left out of degraded until every
implementation's health verdict reads it, rather than added to one of them.
findxpand doctor is the outside-in version of that reading, and the one that
can also tell you our own request never reached the middleware at all.
Pushes are signed, not merely authenticated
Every manifest push carries x-findxpand-signature and x-findxpand-timestamp:
HMAC-SHA256 over timestamp.body, keyed by your token. Unsigned, altered or
stale pushes are refused with 401.
The bearer token proves who sent a push and says nothing about what was in it. Anything holding it — a logging proxy, a mirrored request, an old CI secret — could otherwise replay a captured manifest indefinitely, or swap the manifest inside one. The timestamp is inside the digest, so a captured push expires; the window is 300 seconds, which means the clock on that host has to be roughly right.
There is one supported reason to turn this off, and it is an intermediary that rewrites or re-encodes request bodies, since that invalidates any signature over them.
Why it never calls us
It cannot. There is no outbound request anywhere in this package, and no runtime dependencies at all. If Findxpand is unavailable, your site serves the last manifest it received, indefinitely.
