diff --git a/frontend/.env.example b/frontend/.env.example index a451b77..9f61495 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -4,6 +4,11 @@ BACKEND_URL=http://localhost:8000 # Supported as a deployment-compatible fallback for existing environments. NEXT_PUBLIC_BACKEND_URL=http://localhost:8000 +# Optional public backend origin used only for the unauthenticated health +# probe that wakes a sleeping Render service. Login and private data requests +# continue through BACKEND_URL and the same-origin frontend proxy. +NEXT_PUBLIC_BACKEND_WAKE_URL=http://localhost:8000 + # Same-browser WordPress launcher used only when the app is opened outside the # [calorieapp_embed] page. Keep this on the canonical CalorieToken site. NEXT_PUBLIC_WORDPRESS_APP_URL=https://calorietoken.net/calorieapp/ diff --git a/frontend/components/FoodSearchPlaceholder.tsx b/frontend/components/FoodSearchPlaceholder.tsx index a4dbfce..2a9908e 100644 --- a/frontend/components/FoodSearchPlaceholder.tsx +++ b/frontend/components/FoodSearchPlaceholder.tsx @@ -14,6 +14,7 @@ import { } from "@/components/authEvents"; import type { AuthStateChangedDetail } from "@/components/authEvents"; import { + BACKEND_WAKE_BASE_URL, backendRequest, backendUnavailableMessage, waitForBackendReady, @@ -372,7 +373,7 @@ export function FoodSearchPlaceholder() { ); try { - await waitForBackendReady(BACKEND_BASE_URL, controller.signal); + await waitForBackendReady(BACKEND_WAKE_BASE_URL, controller.signal); setSearchStatus("Searching foods..."); const response = await backendRequest( diff --git a/frontend/components/XamanLoginPanel.tsx b/frontend/components/XamanLoginPanel.tsx index a0e5ab7..24705e5 100644 --- a/frontend/components/XamanLoginPanel.tsx +++ b/frontend/components/XamanLoginPanel.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { announceAuthState } from "@/components/authEvents"; import { + BACKEND_WAKE_BASE_URL, backendRequest, backendUnavailableMessage, BackendRequestTimeoutError, @@ -332,7 +333,7 @@ export async function prepareEmbeddedLogin( throw signal.reason ?? new Error("Login cancelled"); } onProgress("waking-up"); - await waitForBackendReady(BACKEND_BASE_URL, signal); + await waitForBackendReady(BACKEND_WAKE_BASE_URL, signal); return startLoginWithRetry(signal, onProgress, retryWindowMs); } @@ -699,10 +700,10 @@ export function XamanLoginPanel() { } }, 8_000); - // Keep the mobile browser on the CalorieApp origin while Render wakes. - // A direct cross-origin health request can be blocked by browser privacy - // controls before the WordPress/Xaman navigation has even started. - await waitForBackendReady(BACKEND_BASE_URL, controller.signal); + // Wake Render directly from the browser. Requests relayed from the + // frontend Render service can be rate-limited without starting the + // sleeping backend; the health probe is public and sends no credentials. + await waitForBackendReady(BACKEND_WAKE_BASE_URL, controller.signal); window.clearTimeout(startupNoticeTimer); startupNoticeTimer = null; setLoginStatus("Service ready. Opening Xaman..."); diff --git a/frontend/lib/backendRequest.ts b/frontend/lib/backendRequest.ts index e91a4ec..384d48d 100644 --- a/frontend/lib/backendRequest.ts +++ b/frontend/lib/backendRequest.ts @@ -1,6 +1,13 @@ export const DEFAULT_BACKEND_TIMEOUT_MS = 20_000; export const DEFAULT_BACKEND_WARMUP_TIMEOUT_MS = 180_000; +// Render may not wake one free service from another free service's proxy +// request. In production this public URL lets the browser wake the backend +// directly with the unauthenticated health probe; all application requests +// continue to use the same-origin proxy. +export const BACKEND_WAKE_BASE_URL = + process.env.NEXT_PUBLIC_BACKEND_WAKE_URL?.trim() || "/api/backend"; + const BACKEND_WARMUP_ATTEMPT_TIMEOUT_MS = 70_000; const BACKEND_WARMUP_INITIAL_RETRY_DELAY_MS = 5_000; const BACKEND_WARMUP_MAX_RETRY_DELAY_MS = 30_000; diff --git a/tools/tests/xaman_login_start_retry.test.mjs b/tools/tests/xaman_login_start_retry.test.mjs index 0073182..ecfbdb4 100644 --- a/tools/tests/xaman_login_start_retry.test.mjs +++ b/tools/tests/xaman_login_start_retry.test.mjs @@ -83,6 +83,7 @@ test("login start retries transport errors and transient responses", async () => } if (specifier === "@/lib/backendRequest") { return { + BACKEND_WAKE_BASE_URL: "https://backend.example", backendRequest, backendUnavailableMessage: (_error, fallback) => fallback, BackendRequestTimeoutError: TestBackendRequestTimeoutError, @@ -169,14 +170,15 @@ test("embedded login wakes the backend before creating login state", async () => } if (specifier === "@/lib/backendRequest") { return { + BACKEND_WAKE_BASE_URL: "https://backend.example", backendRequest: async () => { events.push("login-start"); return loginResponse; }, backendUnavailableMessage: (_error, fallback) => fallback, BackendRequestTimeoutError: TestBackendRequestTimeoutError, - waitForBackendReady: async () => { - events.push("backend-ready"); + waitForBackendReady: async (backendUrl) => { + events.push(`backend-ready:${backendUrl}`); }, }; } @@ -201,7 +203,10 @@ test("embedded login wakes the backend before creating login state", async () => 10_000 ); - assert.deepEqual(events, ["backend-ready", "login-start"]); + assert.deepEqual(events, [ + "backend-ready:https://backend.example", + "login-start", + ]); assert.deepEqual(phases, ["waking-up"]); assert.equal(result.state, "state-abcdefghijklmnopqrstuvwxyz-0123456789"); }); @@ -244,6 +249,7 @@ test("embedded login does not report progress after cancellation", async () => { } if (specifier === "@/lib/backendRequest") { return { + BACKEND_WAKE_BASE_URL: "https://backend.example", backendRequest: async () => { events.push("login-start"); },