diff --git a/loop-library/worker/src/loop-routes.js b/loop-library/worker/src/loop-routes.js index 8365d3f..2bf655c 100644 --- a/loop-library/worker/src/loop-routes.js +++ b/loop-library/worker/src/loop-routes.js @@ -9,6 +9,7 @@ import { renderAgentInstructions, renderCatalogMarkdown, renderFeed, + renderHomepageFallback, renderLoopPage, renderSitemap, } from "./render-loops.js"; @@ -159,7 +160,33 @@ export async function handleLoopRoute( conditional: false, range: false, }); - const originResponse = await dependencies.fetch(originRequest); + let originResponse; + try { + originResponse = await dependencies.fetch(originRequest); + } catch { + // The here.now shell is unreachable. Serve a branded fallback built from + // the catalog the Worker already holds so every loop stays reachable, + // rather than leaking the upstream failure. Report it honestly as 502. + return textResponse( + renderHomepageFallback(loops), + "text/html; charset=utf-8", + 502, + CACHE_HEADERS, + request.method, + ); + } + + if (originResponse.status >= 500) { + // Upstream shell error: serve the same fallback and preserve the real + // failure status instead of passing through the upstream error page. + return textResponse( + renderHomepageFallback(loops), + "text/html; charset=utf-8", + originResponse.status, + CACHE_HEADERS, + request.method, + ); + } if (!originResponse.ok) { return originResponse; diff --git a/loop-library/worker/src/render-loops.js b/loop-library/worker/src/render-loops.js index 9dcf477..8f45fd1 100644 --- a/loop-library/worker/src/render-loops.js +++ b/loop-library/worker/src/render-loops.js @@ -384,6 +384,69 @@ export function renderLoopPage(loop, loops) { `; } +export function renderHomepageFallback(loops) { + const items = loops + .map( + (loop) => + `
  • ${escapeHtml(loop.title)}

    ${escapeHtml(loop.summary)}

  • `, + ) + .join("\n"); + + return ` + + + + + + + + + + + + + + + ${escapeHtml(SITE.name)} — temporarily unavailable + + + + +
    +

    The Loop Library is briefly unavailable

    +

    The full homepage could not be loaded right now. Every published loop is still listed below, and the machine-readable catalog.json and agent instructions remain available.

    +

    Showing ${loops.length} loops.

    + +
    + + +`; +} + function shareActions(loop, url) { const text = `Try "${loop.title}" from the Loop Library: ${loop.summary}`; return `
    `; diff --git a/loop-library/worker/test/loop-routes.test.js b/loop-library/worker/test/loop-routes.test.js index 9df6e3c..6aaf479 100644 --- a/loop-library/worker/test/loop-routes.test.js +++ b/loop-library/worker/test/loop-routes.test.js @@ -472,6 +472,53 @@ test("renders the mounted homepage through a here.now proxy", async () => { assert.match(await response.text(), /The database publishing loop/); }); +test("serves a branded fallback homepage when the here.now shell errors", async () => { + const env = makeEnv(); + await handleRequest(adminRequest(exampleLoop()), env); + const response = await handleRequest( + new Request(`${SITE_ORIGIN}/loop-library/`), + env, + undefined, + { + async fetch() { + return new Response("upstream is down", { status: 503 }); + }, + }, + ); + const html = await response.text(); + + // Preserves the real failure status instead of masking it as 200. + assert.equal(response.status, 503); + assert.equal(response.headers.get("Cache-Control"), "no-store"); + // Branded fallback that still lists every loop, not the upstream error body. + assert.doesNotMatch(html, /upstream is down/); + assert.match(html, /briefly unavailable/); + assert.match(html, /The database publishing loop/); + assert.match(html, /loops\/database-publishing-loop\//); + assert.match(html, /catalog\.json/); + assert.match(html, / { + const env = makeEnv(); + await handleRequest(adminRequest(exampleLoop()), env); + const response = await handleRequest( + new Request(`${SITE_ORIGIN}/loop-library/`), + env, + undefined, + { + async fetch() { + throw new Error("connection refused"); + }, + }, + ); + const html = await response.text(); + + assert.equal(response.status, 502); + assert.match(html, /briefly unavailable/); + assert.match(html, /The database publishing loop/); +}); + test("normalizes legacy Forward Future domains on every public catalog surface", async () => { const env = makeEnv(); await handleRequest(