From a74acc462db74f7496c0ebf28268aea74fe7dd06 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 5 Jul 2026 02:03:30 -0700 Subject: [PATCH 1/2] fix(website): bound sitemap build-time directory fetch so the build never depends on a live backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `next build` prerenders app/sitemap.ts, which reads the directory. It used the default client (30s timeout x 3 retries = up to ~90s), so a slow or down backend would hang the build past Next's per-route timeout and fail it — exactly the staging-502 failure mode seen in CI. - Add createReadOnlyClient(): an unauthenticated, retry-free, short-timeout (8s) client for build-time/crawler reads. The existing try/catch still falls back to static routes; this only bounds how long an outage may block. - Point the CI Build job's NEXT_PUBLIC_API_BASE_URL at an unreachable sentinel so the build provably never contacts staging (reads fail fast -> static). Verified: `next build` against an unreachable backend now completes in ~9s with /sitemap.xml prerendered statically, instead of hanging. Claude-Session: https://claude.ai/code/session_01EJDqbVAWGNdmx9njh4hME8 --- .github/workflows/ci.yml | 6 ++++++ website/app/sitemap.ts | 6 ++++-- website/src/common/api-client.ts | 19 ++++++++++++++++++- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d30da524..90118bcf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -131,6 +131,12 @@ jobs: name: Build runs-on: ubuntu-latest needs: [lint, typecheck] + # The build must not depend on a live backend. `next build` prerenders + # routes (e.g. app/sitemap.ts reads the directory), so point the API base at + # an unreachable sentinel: the reads fail fast and degrade to static output, + # instead of hanging the build when staging is slow or down. + env: + NEXT_PUBLIC_API_BASE_URL: http://127.0.0.1:9999 steps: - uses: actions/checkout@v4 diff --git a/website/app/sitemap.ts b/website/app/sitemap.ts index a50fd2dc..153de6a3 100644 --- a/website/app/sitemap.ts +++ b/website/app/sitemap.ts @@ -1,6 +1,6 @@ import type { MetadataRoute } from "next"; -import { createClient } from "@src/common/api-client"; +import { createReadOnlyClient } from "@src/common/api-client"; import { profileHref } from "@src/common/profile-link"; import { SITE_URL } from "@src/common/site"; @@ -48,7 +48,9 @@ const STATIC_ROUTES: Array<{ */ async function fetchProfileEntries(now: Date): Promise { try { - const client = createClient(); + // Bounded, retry-free client: a directory outage at build time degrades + // to static routes within seconds instead of hanging `next build`. + const client = createReadOnlyClient(); const { agents } = await client.directory.listAgents({ limit: MAX_PROFILES, }); diff --git a/website/src/common/api-client.ts b/website/src/common/api-client.ts index c1c62b7a..224d0c4f 100644 --- a/website/src/common/api-client.ts +++ b/website/src/common/api-client.ts @@ -5,7 +5,7 @@ import { type Signer, } from "@tinyhumansai/tinyplace"; -const API_BASE_URL = +export const API_BASE_URL = process.env["NEXT_PUBLIC_API_BASE_URL"] ?? "https://staging-api.tiny.place"; /** @@ -28,6 +28,23 @@ export function createClient( }); } +/** + * Builds an unauthenticated client for build-time / crawler reads (sitemap, + * static metadata) with a hard time bound and no retries. The default client + * retries idempotent reads twice at a 30s timeout each (up to ~90s), which + * exceeds Next's per-route build timeout — a slow or down backend would then + * hang `next build` instead of degrading gracefully. Callers must still guard + * with try/catch and fall back to static content: this only bounds *how long* + * an outage is allowed to block, never whether it can. + */ +export function createReadOnlyClient(timeoutMs = 8000): TinyPlaceClient { + return new TinyPlaceClient({ + baseUrl: API_BASE_URL, + timeoutMs, + retry: { retries: 0 }, + }); +} + /** * Builds a signer-less TinyPlace client authorized by a bearer onboarding grant. * The onboarding flow (agnostic of any logged-in wallet) uses this to act on the From 4eafbfe5ab4390f19fb9c69f1f4791a95598efcd Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 5 Jul 2026 02:03:42 -0700 Subject: [PATCH 2/2] test(website): add hermetic mocked-backend e2e smoke that runs in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every existing Playwright spec is gated behind E2E_LIVE / E2E_X402 / API_URL and skips in CI, so the E2E job's only real work was `next build` — which reached staging and failed when staging was down. Nothing actually exercised the app against a mocked backend. - Add smoke-mocked-backend.spec.ts: always-on, intercepts every backend call (Playwright fulfils before the network), and asserts the static shell renders offline (home hero + /terms). This is the guard that the app boots with no live backend. - Point the E2E job's NEXT_PUBLIC_API_BASE_URL at an unreachable sentinel so the build and served app never contact staging; mocked specs intercept that origin. Verified: full suite CI-style => 22 live-only specs skip, 2 smoke tests pass, zero staging contact. Claude-Session: https://claude.ai/code/session_01EJDqbVAWGNdmx9njh4hME8 --- .github/workflows/e2e.yml | 7 +++ website/e2e/smoke-mocked-backend.spec.ts | 77 ++++++++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 website/e2e/smoke-mocked-backend.spec.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index af874a42..9373f100 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -12,6 +12,13 @@ jobs: e2e: name: Playwright runs-on: ubuntu-latest + # The Playwright suite mocks the backend (page.route) or skips live-only + # specs, so it must not reach staging. Build and serve against an unreachable + # sentinel API base: the app never talks to a real backend, and the mocked + # specs intercept requests to this origin. Keeps CI hermetic and staging + # outages from failing the build/tests. + env: + NEXT_PUBLIC_API_BASE_URL: http://127.0.0.1:9999 steps: - uses: actions/checkout@v4 diff --git a/website/e2e/smoke-mocked-backend.spec.ts b/website/e2e/smoke-mocked-backend.spec.ts new file mode 100644 index 00000000..754db4c7 --- /dev/null +++ b/website/e2e/smoke-mocked-backend.spec.ts @@ -0,0 +1,77 @@ +import { expect, test, type Route } from "@playwright/test"; + +// Hermetic smoke test: the web app must boot and render its shell without any +// live backend. Unlike the other e2e specs (gated behind E2E_LIVE / E2E_X402 / +// API_URL against a real stack), this one ALWAYS runs in CI — it is the guard +// that the build + app never depend on staging. Every backend call is +// intercepted by Playwright before it can reach the network, so no real backend +// is ever contacted; the assertions cover only static, data-independent shell +// content that must render regardless. +// +// The app resolves its API base from NEXT_PUBLIC_API_BASE_URL (an unreachable +// sentinel in CI, staging by default locally). We intercept that origin AND +// staging explicitly, so a real backend is never contacted in any environment. +const API_BASE = + process.env["NEXT_PUBLIC_API_BASE_URL"] ?? "https://staging-api.tiny.place"; + +const BACKEND_GLOBS = [ + `${API_BASE}/**`, + "**/staging-api.tiny.place/**", + "**/127.0.0.1:9999/**", +]; + +const CORS = { + "access-control-allow-origin": "*", + "access-control-allow-headers": "*", + "access-control-allow-methods": "*", +}; + +/** + * Simulate a backend that is simply not there: fulfil CORS preflights, and + * answer everything else with an empty, well-formed body. TanStack Query hooks + * degrade to empty/error states; the static shell renders regardless. We never + * abort (that races page teardown and flakes) — an explicit empty response is + * deterministic. + */ +function emptyBackend(route: Route): Promise { + const request = route.request(); + if (request.method() === "OPTIONS") { + return route.fulfill({ status: 204, headers: CORS }); + } + // GraphQL clients expect a { data } envelope; REST callers tolerate {}. + const body = request.url().includes("/graphql") ? { data: {} } : {}; + return route.fulfill({ + status: 200, + headers: { ...CORS, "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +test.describe("mocked-backend smoke", () => { + test.beforeEach(async ({ page }) => { + // Intercepting these globs guarantees no request reaches a real backend: + // Playwright fulfils them before the network layer runs. + for (const glob of BACKEND_GLOBS) { + await page.route(glob, emptyBackend); + } + }); + + test("home shell renders with no live backend", async ({ page }) => { + await page.goto("/"); + + // Static hero copy: present regardless of any backend data. Its presence + // proves the layout + i18n + client bootstrap all work with the backend + // fully mocked out. + await expect( + page.getByText("Enter as a Human", { exact: false }) + ).toBeVisible(); + }); + + test("static content route renders offline", async ({ page }) => { + // /terms is server-rendered static section content — a route with no + // backend dependency that must always serve. + const response = await page.goto("/terms"); + expect(response?.status()).toBeLessThan(400); + await expect(page.locator("body")).toBeVisible(); + }); +});