Pular para o conteúdo

Cap. 22 · Código

Worker 410 com liveness check

Um Cloudflare Worker que serve HTTP 410 Gone na borda para uma lista curada de URLs aposentadas — mas só depois de confirmar com a origem (WordPress) que o conteúdo realmente sumiu. A lista vem do urls-removidas.txt, não do código.
Cloudflare Workers · wrangler

Execução

Deploy no Cloudflare

terminal
$ npm i -g wrangler
$ wrangler login
$ cp wrangler.toml.example wrangler.toml
# edite account_id, WP_API e as rotas (routes) / edit account_id, WP_API and routes
$ wrangler deploy

Defina env.WP_API (origem WordPress) para o liveness check funcionar. Sem ele, o Worker falha em segurança e repassa a requisição.

Código-fonte

Código do módulo

worker.js
/**
* worker.js — Cap. 22: "Aparecer ou Sumir"
*
* Cloudflare Worker (ES module) that returns HTTP 410 Gone at the edge for a
* curated list of dead URL paths — BUT only after confirming with the origin
* that the content is really gone (liveness check).
*
* Why 410 instead of 404? A 410 tells Google (and other crawlers) the resource
* is permanently gone, so it is dropped from the index faster than a 404, which
* is treated as "maybe temporary". Serving it at the edge means the origin is
* never even hit for these paths once the verdict is cached.
*
* The dead-URL list is NOT hardcoded in this file: it is LOADED from the bundled
* `urls-removidas.txt` (embedded below as URLS_REMOVIDAS_TXT and parsed at
* startup by parseRemovedUrls). Edit the .txt, redeploy, and the Worker picks up
* the new list — no code changes. Extra paths can still be appended at runtime
* via env.GONE_PATHS (comma-separated).
*
* The safety net — LIVENESS CHECK:
* Before serving 410, the Worker asks the origin (WordPress REST API) whether
* the content is *really* gone. If WordPress unexpectedly answers 200, the page
* was probably revived / republished, so we DO NOT serve 410 — we pass the
* request through to origin. This prevents us from nuking content that came
* back to life. The liveness result is cached briefly (via the Cache API) so we
* don't hammer the origin on every request.
*
* Configuration comes from `env` (see wrangler.toml.example):
* env.WP_API -> base URL of the WordPress REST API,
* e.g. "https://origin.exemplo.com" (TODO: set this)
* env.GONE_PATHS -> (optional) comma-separated extra paths to treat as gone.
*/
/**
* The removed-URL list, embedded verbatim from `urls-removidas.txt`.
* Keeping it inline means the Worker bundle is fully self-contained (Workers
* have no filesystem at runtime) while the .txt in the repo stays the single
* editable source of truth — the two are shipped together in the same module.
*/
const URLS_REMOVIDAS_TXT = `# urls-removidas.txt — Cap. 22
# Uma URL removida por linha (apenas o path, sem domínio).
# Linhas em branco e linhas iniciadas por "#" são ignoradas.
# Comparação é case-insensitive e ignora a barra final.
# TODO: troque estes exemplos pelas suas próprias URLs aposentadas.
/promo-black-friday-2019
/produto/kit-antigo-descontinuado
/blog/post-que-foi-despublicado
/categoria/colecao-verao-2020
/servicos/plano-legado
/lp/campanha-encerrada
/loja/estoque-liquidado
/eventos/webinar-2021-03
`;
/** How long (seconds) to cache a liveness decision at the edge. */
const LIVENESS_TTL_SECONDS = 300; // 5 minutes
/** Normalises a pathname for comparison: lowercase, no trailing slash. */
function normalisePath(pathname) {
let p = pathname.toLowerCase();
if (p.length > 1 && p.endsWith("/")) p = p.slice(0, -1);
return p;
}
/**
* Parses the contents of `urls-removidas.txt` into a Set of normalised paths.
* Ignores blank lines and comment lines (starting with "#"). This is the small
* embedded parser that lets the .txt drive the Worker's behaviour.
*/
function parseRemovedUrls(text) {
const set = new Set();
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
set.add(normalisePath(line));
}
return set;
}
/** Dead paths loaded once from the bundled list at module init. */
const DEFAULT_GONE_PATHS = parseRemovedUrls(URLS_REMOVIDAS_TXT);
/**
* Builds the Set of gone paths, merging the list parsed from the .txt with any
* extra paths provided via env.GONE_PATHS (comma-separated).
*/
function buildGoneSet(env) {
const set = new Set(DEFAULT_GONE_PATHS);
if (env && typeof env.GONE_PATHS === "string" && env.GONE_PATHS.trim()) {
for (const raw of env.GONE_PATHS.split(",")) {
const p = normalisePath(raw.trim());
if (p) set.add(p);
}
}
return set;
}
/** The 410 response body + headers actually served to clients and crawlers. */
function goneResponse() {
const body =
"<!doctype html><html lang=\"pt-BR\"><head><meta charset=\"utf-8\">" +
"<title>410 - Conteúdo removido</title></head><body>" +
"<h1>410 - Conteúdo removido permanentemente</h1>" +
"<p>Esta página foi removida e não está mais disponível.</p>" +
"</body></html>";
return new Response(body, {
status: 410,
statusText: "Gone",
headers: {
"Content-Type": "text/html; charset=UTF-8",
// Let crawlers cache the "gone" verdict; adjust to taste.
"Cache-Control": "public, max-age=3600",
// Belt-and-suspenders: keep these out of the index even if rendered.
"X-Robots-Tag": "noindex",
},
});
}
/**
* Liveness check: asks the origin WordPress REST API whether the content at
* `pathname` still exists. Returns true if the content appears GONE (safe to
* serve 410), false if it looks ALIVE (should pass through).
*
* Strategy: query the WP REST API search endpoint for the slug. If WordPress
* returns 404 for the mapped resource, or the search yields no live match, we
* treat the content as gone. If it returns 200 with a live match, it is alive.
*
* Results are cached at the edge for LIVENESS_TTL_SECONDS using the Cache API,
* keyed by a synthetic URL, so repeated hits don't re-query the origin.
*
* On any error (origin unreachable, timeout, malformed response) we FAIL SAFE
* by returning false (alive) — i.e. we do NOT serve 410 if we can't confirm the
* content is gone. Better to serve the page than to wrongly 410 live content.
*/
async function isContentGone(pathname, env, ctx) {
// Without a configured origin we cannot verify; fail safe (treat as alive).
if (!env || !env.WP_API) {
// TODO: set env.WP_API in wrangler.toml so liveness checks can run.
return false;
}
const cache = caches.default;
// Synthetic cache key — never actually fetched, just used as a cache handle.
const cacheKey = new Request(
`https://liveness.internal/${encodeURIComponent(pathname)}`,
{ method: "GET" },
);
// 1) Try the edge cache first.
const cached = await cache.match(cacheKey);
if (cached) {
const verdict = await cached.text();
return verdict === "gone";
}
let gone;
try {
// The slug is the last path segment; used to search WordPress.
const slug = pathname.split("/").filter(Boolean).pop() || "";
const apiUrl =
`${env.WP_API.replace(/\/$/, "")}` +
`/wp-json/wp/v2/search?search=${encodeURIComponent(slug)}&per_page=1`;
const originResponse = await fetch(apiUrl, {
headers: { Accept: "application/json" },
cf: { cacheTtl: 0 },
});
if (originResponse.status === 404) {
// Endpoint/resource explicitly missing -> gone.
gone = true;
} else if (originResponse.status === 200) {
const results = await originResponse.json();
// If WordPress returns a live match whose URL ends with our path, the
// content is ALIVE (revived). Otherwise treat as gone.
const alive =
Array.isArray(results) &&
results.some((item) => {
if (!item || typeof item.url !== "string") return false;
try {
return normalisePath(new URL(item.url).pathname) ===
normalisePath(pathname);
} catch {
return false;
}
});
gone = !alive;
} else {
// Any other status (5xx, etc.) is inconclusive -> fail safe as alive.
gone = false;
}
} catch (error) {
// Network/parse failure -> fail safe: do not 410 content we can't verify.
// eslint-disable-next-line no-console
console.error("liveness check failed:", error);
gone = false;
}
// 2) Store the verdict in the edge cache for a short while.
const verdictResponse = new Response(gone ? "gone" : "alive", {
headers: { "Cache-Control": `max-age=${LIVENESS_TTL_SECONDS}` },
});
// waitUntil so caching doesn't delay the response to the user.
if (ctx && typeof ctx.waitUntil === "function") {
ctx.waitUntil(cache.put(cacheKey, verdictResponse.clone()));
}
return gone;
}
export default {
/**
* @param {Request} request
* @param {Record<string, string>} env
* @param {{ waitUntil: (p: Promise<unknown>) => void }} ctx
*/
async fetch(request, env, ctx) {
const url = new URL(request.url);
const path = normalisePath(url.pathname);
const goneSet = buildGoneSet(env);
// Not a curated dead path -> nothing to do, pass straight through.
if (!goneSet.has(path)) {
return fetch(request);
}
// Curated as dead. Confirm with the origin before serving 410.
const gone = await isContentGone(url.pathname, env, ctx);
if (gone) {
return goneResponse();
}
// Content unexpectedly alive (or unverifiable) -> pass through to origin
// so we never accidentally 410 revived content.
return fetch(request);
},
};
urls-removidas.txt
# urls-removidas.txt — Cap. 22
# Uma URL removida por linha (apenas o path, sem domínio).
# Linhas em branco e linhas iniciadas por "#" são ignoradas.
# Comparação é case-insensitive e ignora a barra final.
# TODO: troque estes exemplos pelas suas próprias URLs aposentadas.
/promo-black-friday-2019
/produto/kit-antigo-descontinuado
/blog/post-que-foi-despublicado
/categoria/colecao-verao-2020
/servicos/plano-legado
/lp/campanha-encerrada
/loja/estoque-liquidado
/eventos/webinar-2021-03
wrangler.toml.example
# wrangler.toml.example — Cap. 22: "Aparecer ou Sumir"
#
# Copy this file to `wrangler.toml` and fill in your own values.
# Deploy with: npx wrangler deploy
#
# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/
name = "worker-410-gone"
main = "worker.js"
compatibility_date = "2026-01-01"
# TODO: set your Cloudflare account ID (Dashboard -> Workers -> right sidebar).
account_id = "TODO_YOUR_CLOUDFLARE_ACCOUNT_ID"
# Route(s) this Worker runs on.
# TODO: replace with your real zone / hostname pattern.
# routes = [
# { pattern = "exemplo.com/*", zone_name = "exemplo.com" }
# ]
[vars]
# TODO: base URL of your WordPress origin (used by the liveness check).
WP_API = "https://origin.exemplo.com"
# TODO (optional): extra dead paths as a comma-separated list. These are merged
# with the list parsed from urls-removidas.txt (embedded in worker.js).
GONE_PATHS = "/promo-antiga,/outra-url-morta"

Dúvidas

Perguntas frequentes

Por que 410 em vez de 404?

O 410 Gone diz ao Google que o conteúdo saiu de forma permanente, então ele é removido do índice mais rápido que um 404, que é tratado como algo possivelmente temporário. Servido na borda, a origem nem é consultada para esses paths.

O que é o liveness check e por que ele importa?

Antes de servir 410, o Worker pergunta à origem (WordPress REST API) se o conteúdo realmente sumiu. Se a origem responder 200 (página revivida), o Worker repassa a requisição em vez de derrubar conteúdo que voltou. Em qualquer erro ele falha em segurança e trata como vivo.

Como a lista de URLs mortas é carregada?

Ela vem do urls-removidas.txt, embutido no worker.js e interpretado por um parser pequeno (parseRemovedUrls). Edite o .txt, refaça o deploy e a lista muda sem tocar na lógica. Paths extras podem ser somados em runtime via env.GONE_PATHS.

Implementação

Prefere que eu implemente?

Conte o seu caso numa conversa e a gente define o escopo juntos.

Agendar conversa