From f2e5209b8290df407f76851eb9402a6555571ec5 Mon Sep 17 00:00:00 2001 From: JakeSCahill Date: Tue, 20 Jan 2026 09:10:24 +0000 Subject: [PATCH 1/6] Add the Bump proxy for previewing API docs in PRs --- netlify.toml | 10 ++ netlify/edge-functions/proxy-api-docs.js | 185 +++++++++++++++++++++++ 2 files changed, 195 insertions(+) create mode 100644 netlify.toml create mode 100644 netlify/edge-functions/proxy-api-docs.js diff --git a/netlify.toml b/netlify.toml new file mode 100644 index 000000000..57dde9ba3 --- /dev/null +++ b/netlify.toml @@ -0,0 +1,10 @@ +[build.environment] +NODE_VERSION = "24" + +[dev] + publish = "docs/" + framework = "#static" + +[[edge_functions]] +path = "/api/*" +function = "proxy-api-docs" diff --git a/netlify/edge-functions/proxy-api-docs.js b/netlify/edge-functions/proxy-api-docs.js new file mode 100644 index 000000000..b3445c44a --- /dev/null +++ b/netlify/edge-functions/proxy-api-docs.js @@ -0,0 +1,185 @@ +import { DOMParser } from "https://deno.land/x/deno_dom@v0.1.56/deno-dom-wasm.ts"; + +export default async (request, context) => { + const url = new URL(request.url); + const originalOrigin = url.origin; + + // Redirects from old API paths to new ones + const redirects = { + "/api/doc": "/api", + "/api/admin-api": "/api/doc/admin/", + "/api/http-proxy-api": "/api/doc/http-proxy/", + "/api/schema-registry-api": "/api/doc/schema-registry/", + "/api/cloud-controlplane-api": "/api/doc/cloud-controlplane/", + "/api/cloud-dataplane-api": "/api/doc/cloud-dataplane/", + "/api/cloud-api": "/api/doc/cloud-controlplane/", + }; + + const normalizedPath = url.pathname.endsWith("/") + ? url.pathname.slice(0, -1) + : url.pathname; + + if (redirects[normalizedPath]) { + return Response.redirect(`${url.origin}${redirects[normalizedPath]}`, 301); + } + + // Map paths to header background colors + const headerColors = { + "/api/doc/admin": "#107569", + "/api/doc/cloud-controlplane": "#014F86", + "/api/doc/cloud-dataplane": "#014F86", + }; + + const matchedPath = Object.keys(headerColors).find((path) => + normalizedPath.startsWith(path) + ); + const headerColor = headerColors[matchedPath] || "#d73d23"; + + // Build the proxied Bump.sh URL + const bumpUrl = new URL(request.url); + bumpUrl.host = "bump.sh"; + bumpUrl.pathname = `/redpanda/hub/redpanda${bumpUrl.pathname.replace("/api", "")}`; + + const secret = Netlify.env.get("BUMP_PROXY_SECRET"); + + // Validate secret exists + if (!secret) { + console.error("❌ BUMP_PROXY_SECRET environment variable not set"); + return new Response("Service temporarily unavailable", { status: 503 }); + } + + try { + const bumpRes = await fetchWithRetry(bumpUrl, { + headers: { + "X-BUMP-SH-PROXY": secret, + "X-BUMP-SH-EMBED": "true", + "User-Agent": "Redpanda-Docs-Proxy/1.0", + }, + }); + + // Handle non-successful responses + if (!bumpRes.ok) { + console.error(`❌ Bump.sh returned ${bumpRes.status}: ${bumpRes.statusText}`); + throw new Error(`Bump.sh API error: ${bumpRes.status}`); + } + + const contentType = bumpRes.headers.get("content-type") || ""; + + if (!contentType.includes("text/html")) { + return bumpRes; + } + + // Load Bump.sh page and widgets + const [ + originalHtml, + headScript, + headerWidget, + footerWidget, + ] = await Promise.all([ + bumpRes.text(), + fetchWidget(`${originalOrigin}/assets/widgets/head-bump.html`, "head-bump"), + fetchWidget(`${originalOrigin}/assets/widgets/header.html`, "header"), + fetchWidget(`${originalOrigin}/assets/widgets/footer.html`, "footer"), + ]); + + const document = new DOMParser().parseFromString(originalHtml, "text/html"); + if (!document) { + console.error("❌ Failed to parse Bump.sh HTML."); + return new Response(originalHtml, { + status: 200, + headers: { "content-type": "text/html; charset=utf-8" }, + }); + } + + // Inject head script + const head = document.querySelector("head"); + if (head && headScript) { + const temp = document.createElement("div"); + temp.innerHTML = headScript; + for (const node of temp.childNodes) { + head.appendChild(node); + } + } + + // Inject header with dynamic background color + const topBody = document.querySelector("#embed-top-body"); + if (topBody && headerWidget) { + const coloredHeader = headerWidget.replace( + /(]*style="[^"]*background-color:\s*)#[^";]+/, + `$1${headerColor}` + ); + + const wrapper = document.createElement("div"); + wrapper.innerHTML = coloredHeader; + while (wrapper.firstChild) { + topBody.appendChild(wrapper.firstChild); + } + } + + // Inject footer + const bottomBody = document.querySelector("#embed-bottom-body"); + if (bottomBody && footerWidget) { + const wrapper = document.createElement("div"); + wrapper.innerHTML = footerWidget; + while (wrapper.firstChild) { + bottomBody.appendChild(wrapper.firstChild); + } + } + + return new Response(document.documentElement.outerHTML, { + status: 200, + headers: { + "content-type": "text/html; charset=utf-8", + "cache-control": "public, max-age=300", // Cache for 5 minutes + }, + }); + + } catch (error) { + console.error("❌ Failed to fetch from Bump.sh after retries:", error); + + // Return a graceful fallback response + return new Response( + `API Documentation Temporarily Unavailable

API Documentation Temporarily Unavailable

Please try again later.

`, + { + status: 503, + headers: { "content-type": "text/html; charset=utf-8" } + } + ); + } +}; + +// Fetch with retry logic and exponential backoff +async function fetchWithRetry(url, options, maxRetries = 3) { + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + const response = await fetch(url, { + ...options, + signal: AbortSignal.timeout(10000), // 10 second timeout + }); + return response; + } catch (error) { + console.warn(`Attempt ${attempt} failed for ${url}:`, error.message); + + if (attempt === maxRetries) { + throw error; + } + + // Exponential backoff: wait 2^attempt seconds + const delay = Math.pow(2, attempt) * 1000; + await new Promise(resolve => setTimeout(resolve, delay)); + } + } +} + +// Helper function to fetch widget content with fallback +async function fetchWidget(url, label) { + try { + const res = await fetchWithRetry(url, {}, 2); // 2 retries for widgets + if (res.ok) return await res.text(); + console.warn(`⚠️ Failed to load ${label} widget from ${url}`); + return ""; + } catch (err) { + console.error(`❌ Error fetching ${label} widget:`, err); + return ""; + } +} From f4efe5928ca00bd1c26d36f62d50542586be5334 Mon Sep 17 00:00:00 2001 From: JakeSCahill Date: Tue, 20 Jan 2026 10:23:27 +0000 Subject: [PATCH 2/6] Use docs.redpanda.com as preview URLs are not authorized for Bump --- netlify/edge-functions/proxy-api-docs.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/netlify/edge-functions/proxy-api-docs.js b/netlify/edge-functions/proxy-api-docs.js index b3445c44a..19b1e0e06 100644 --- a/netlify/edge-functions/proxy-api-docs.js +++ b/netlify/edge-functions/proxy-api-docs.js @@ -2,7 +2,7 @@ import { DOMParser } from "https://deno.land/x/deno_dom@v0.1.56/deno-dom-wasm.ts export default async (request, context) => { const url = new URL(request.url); - const originalOrigin = url.origin; + const productionOrigin = "https://docs.redpanda.com"; // Redirects from old API paths to new ones const redirects = { @@ -77,9 +77,9 @@ export default async (request, context) => { footerWidget, ] = await Promise.all([ bumpRes.text(), - fetchWidget(`${originalOrigin}/assets/widgets/head-bump.html`, "head-bump"), - fetchWidget(`${originalOrigin}/assets/widgets/header.html`, "header"), - fetchWidget(`${originalOrigin}/assets/widgets/footer.html`, "footer"), + fetchWidget(`${productionOrigin}/assets/widgets/head-bump.html`, "head-bump"), + fetchWidget(`${productionOrigin}/assets/widgets/header.html`, "header"), + fetchWidget(`${productionOrigin}/assets/widgets/footer.html`, "footer"), ]); const document = new DOMParser().parseFromString(originalHtml, "text/html"); From 2369221c6a3a17319d61f3360790c2e1330f0252 Mon Sep 17 00:00:00 2001 From: JakeSCahill Date: Tue, 20 Jan 2026 10:37:41 +0000 Subject: [PATCH 3/6] Revert to using request origin for widget fetching --- netlify/edge-functions/proxy-api-docs.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/netlify/edge-functions/proxy-api-docs.js b/netlify/edge-functions/proxy-api-docs.js index 19b1e0e06..b3445c44a 100644 --- a/netlify/edge-functions/proxy-api-docs.js +++ b/netlify/edge-functions/proxy-api-docs.js @@ -2,7 +2,7 @@ import { DOMParser } from "https://deno.land/x/deno_dom@v0.1.56/deno-dom-wasm.ts export default async (request, context) => { const url = new URL(request.url); - const productionOrigin = "https://docs.redpanda.com"; + const originalOrigin = url.origin; // Redirects from old API paths to new ones const redirects = { @@ -77,9 +77,9 @@ export default async (request, context) => { footerWidget, ] = await Promise.all([ bumpRes.text(), - fetchWidget(`${productionOrigin}/assets/widgets/head-bump.html`, "head-bump"), - fetchWidget(`${productionOrigin}/assets/widgets/header.html`, "header"), - fetchWidget(`${productionOrigin}/assets/widgets/footer.html`, "footer"), + fetchWidget(`${originalOrigin}/assets/widgets/head-bump.html`, "head-bump"), + fetchWidget(`${originalOrigin}/assets/widgets/header.html`, "header"), + fetchWidget(`${originalOrigin}/assets/widgets/footer.html`, "footer"), ]); const document = new DOMParser().parseFromString(originalHtml, "text/html"); From 96fdba0b7dd713ea9bca7027ea4b4d3d754250c0 Mon Sep 17 00:00:00 2001 From: JakeSCahill Date: Tue, 20 Jan 2026 10:45:10 +0000 Subject: [PATCH 4/6] Use docs.redpanda.com for widget origin to avoid CORS issues --- netlify/edge-functions/proxy-api-docs.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netlify/edge-functions/proxy-api-docs.js b/netlify/edge-functions/proxy-api-docs.js index b3445c44a..324ebc3b4 100644 --- a/netlify/edge-functions/proxy-api-docs.js +++ b/netlify/edge-functions/proxy-api-docs.js @@ -2,7 +2,7 @@ import { DOMParser } from "https://deno.land/x/deno_dom@v0.1.56/deno-dom-wasm.ts export default async (request, context) => { const url = new URL(request.url); - const originalOrigin = url.origin; + const originalOrigin = "https://docs.redpanda.com"; // Redirects from old API paths to new ones const redirects = { From 3096f0914239c82b14a2a2297c3481eb94dea886 Mon Sep 17 00:00:00 2001 From: JakeSCahill Date: Tue, 20 Jan 2026 11:18:28 +0000 Subject: [PATCH 5/6] Rewrite docs.redpanda.com URLs to current origin to fix CORS and history API errors --- netlify/edge-functions/proxy-api-docs.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/netlify/edge-functions/proxy-api-docs.js b/netlify/edge-functions/proxy-api-docs.js index 324ebc3b4..23e29b766 100644 --- a/netlify/edge-functions/proxy-api-docs.js +++ b/netlify/edge-functions/proxy-api-docs.js @@ -3,6 +3,7 @@ import { DOMParser } from "https://deno.land/x/deno_dom@v0.1.56/deno-dom-wasm.ts export default async (request, context) => { const url = new URL(request.url); const originalOrigin = "https://docs.redpanda.com"; + const currentOrigin = url.origin; // Redirects from old API paths to new ones const redirects = { @@ -82,10 +83,13 @@ export default async (request, context) => { fetchWidget(`${originalOrigin}/assets/widgets/footer.html`, "footer"), ]); - const document = new DOMParser().parseFromString(originalHtml, "text/html"); + // Rewrite absolute docs.redpanda.com URLs to use current origin to avoid CORS and history API issues + const rewrittenHtml = originalHtml.replace(/https:\/\/docs\.redpanda\.com/g, currentOrigin); + + const document = new DOMParser().parseFromString(rewrittenHtml, "text/html"); if (!document) { console.error("❌ Failed to parse Bump.sh HTML."); - return new Response(originalHtml, { + return new Response(rewrittenHtml, { status: 200, headers: { "content-type": "text/html; charset=utf-8" }, }); From 7ba6716f1ee121549a480852f054e6137cff1828 Mon Sep 17 00:00:00 2001 From: JakeSCahill Date: Tue, 20 Jan 2026 11:35:30 +0000 Subject: [PATCH 6/6] Simplify proxy to use request origin for all URLs --- netlify/edge-functions/proxy-api-docs.js | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/netlify/edge-functions/proxy-api-docs.js b/netlify/edge-functions/proxy-api-docs.js index 23e29b766..b3445c44a 100644 --- a/netlify/edge-functions/proxy-api-docs.js +++ b/netlify/edge-functions/proxy-api-docs.js @@ -2,8 +2,7 @@ import { DOMParser } from "https://deno.land/x/deno_dom@v0.1.56/deno-dom-wasm.ts export default async (request, context) => { const url = new URL(request.url); - const originalOrigin = "https://docs.redpanda.com"; - const currentOrigin = url.origin; + const originalOrigin = url.origin; // Redirects from old API paths to new ones const redirects = { @@ -83,13 +82,10 @@ export default async (request, context) => { fetchWidget(`${originalOrigin}/assets/widgets/footer.html`, "footer"), ]); - // Rewrite absolute docs.redpanda.com URLs to use current origin to avoid CORS and history API issues - const rewrittenHtml = originalHtml.replace(/https:\/\/docs\.redpanda\.com/g, currentOrigin); - - const document = new DOMParser().parseFromString(rewrittenHtml, "text/html"); + const document = new DOMParser().parseFromString(originalHtml, "text/html"); if (!document) { console.error("❌ Failed to parse Bump.sh HTML."); - return new Response(rewrittenHtml, { + return new Response(originalHtml, { status: 200, headers: { "content-type": "text/html; charset=utf-8" }, });