Skip to content

Instantly share code, notes, and snippets.

@Lasalot
Created April 14, 2026 09:33
Show Gist options
  • Select an option

  • Save Lasalot/0ab5b1376aa27a447319f233d0171a93 to your computer and use it in GitHub Desktop.

Select an option

Save Lasalot/0ab5b1376aa27a447319f233d0171a93 to your computer and use it in GitHub Desktop.
// User agents handled by Prerender
const BOT_AGENTS = [
"googlebot","yahoo! slurp","bingbot","yandex","baiduspider","facebookexternalhit",
"twitterbot","rogerbot","linkedinbot","embedly","quora link preview","showyoubot",
"outbrain","pinterest/0.","developers.google.com/+/web/snippet","slackbot","vkshare",
"w3c_validator","redditbot","applebot","whatsapp","flipboard","tumblr","bitlybot",
"skypeuripreview","nuzzel","discordbot","google page speed","qwantify","pinterestbot",
"bitrix link preview","xing-contenttabreceiver","chrome-lighthouse","telegrambot",
"oai-searchbot","chatgpt","gptbot","claudebot","amazonbot","perplexity",
"google-inspectiontool","integration-test",
];
// Extensions to skip prerendering even if bot
const IGNORE_EXTENSIONS = [
".js",".css",".xml",".less",".png",".jpg",".jpeg",".gif",".pdf",".doc",".txt",".ico",
".rss",".zip",".mp3",".rar",".exe",".wmv",".avi",".ppt",".mpg",".mpeg",".tif",".wav",
".mov",".psd",".ai",".xls",".mp4",".m4a",".swf",".dat",".dmg",".iso",".flv",".m4v",
".torrent",".woff",".ttf",".svg",".webmanifest",
];
function isRedirect(status) {
return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
}
/**
* Injects or rewrites <link rel="canonical"> in an HTML response.
* Removes any existing canonical and appends one pointing to the public domain.
* Non-HTML responses pass through untouched.
*/
function injectCanonical(response, canonicalUrl) {
const contentType = (response.headers.get("Content-Type") || "").toLowerCase();
if (!contentType.includes("text/html")) {
return response;
}
return new HTMLRewriter()
.on('link[rel="canonical"]', {
element(el) {
el.remove();
},
})
.on("head", {
element(el) {
el.append(
`<link rel="canonical" href="${canonicalUrl}" />`,
{ html: true }
);
},
})
.transform(response);
}
export default {
async fetch(request, env) {
try {
if (!env?.BUBBLE_UPSTREAM) {
return new Response("Missing BUBBLE_UPSTREAM variable", { status: 500 });
}
if (!env?.PRERENDER_TOKEN) {
return new Response("Missing PRERENDER_TOKEN secret", { status: 500 });
}
return await handleRequest(request, env);
} catch (err) {
return new Response(err?.stack || String(err), { status: 500 });
}
},
};
async function handleRequest(request, env) {
const url = new URL(request.url);
const PUBLIC_HOST = url.host;
const userAgent = (request.headers.get("User-Agent") || "").toLowerCase();
const isPrerender = request.headers.get("X-Prerender");
const pathName = url.pathname.toLowerCase();
const dot = pathName.lastIndexOf(".");
const extension = dot >= 0 ? pathName.substring(dot).toLowerCase() : "";
const isBot = BOT_AGENTS.some((bot) => userAgent.includes(bot.toLowerCase()));
const isGetLike = request.method === "GET" || request.method === "HEAD";
const isIgnoredExt = extension && IGNORE_EXTENSIONS.includes(extension);
const canonicalUrl = url.toString();
// 1) Bots -> Prerender (ONLY for GET/HEAD, never assets, never loop)
if (isGetLike && !isPrerender && isBot && !isIgnoredExt) {
const prerenderUrl = `https://service.prerender.io/${encodeURIComponent(url.href)}`;
const newHeaders = new Headers(request.headers);
newHeaders.set("X-Prerender-Token", env.PRERENDER_TOKEN);
newHeaders.set("X-Prerender-Int-Type", "CloudFlare-Bubble");
newHeaders.delete("Host");
const prerenderResp = await fetch(new Request(prerenderUrl, {
headers: newHeaders,
redirect: "manual",
}));
return injectCanonical(prerenderResp, canonicalUrl);
}
// 2) Everyone else -> Bubble upstream
const upstreamBase = new URL(env.BUBBLE_UPSTREAM);
const upstreamUrl = new URL(request.url);
upstreamUrl.protocol = upstreamBase.protocol;
upstreamUrl.hostname = upstreamBase.hostname;
const h = new Headers(request.headers);
h.set("Host", upstreamBase.hostname);
h.set("X-Forwarded-Host", PUBLIC_HOST);
h.set("X-Forwarded-Proto", "https");
h.delete("cf-connecting-ip");
h.delete("x-forwarded-for");
h.delete("forwarded");
const upstreamReq = new Request(upstreamUrl.toString(), {
method: request.method,
headers: h,
body: isGetLike ? undefined : request.body,
redirect: "manual",
});
const resp = await fetch(upstreamReq);
// Rewrite redirects to keep users on the same hostname they came in on
if (isRedirect(resp.status)) {
const loc = resp.headers.get("Location") || "";
let newLoc = loc.replaceAll(upstreamBase.hostname, PUBLIC_HOST);
newLoc = newLoc.replace(/^http:\/\//i, "https://");
const newHeaders = new Headers(resp.headers);
if (loc) newHeaders.set("Location", newLoc);
return new Response(resp.body, { status: resp.status, headers: newHeaders });
}
return injectCanonical(resp, canonicalUrl);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment