diff --git a/frontend/lib/backendRequest.ts b/frontend/lib/backendRequest.ts index 2bc9310..1827e4d 100644 --- a/frontend/lib/backendRequest.ts +++ b/frontend/lib/backendRequest.ts @@ -140,6 +140,53 @@ async function discardResponseBody(response: Response) { } } +function mountBackendWakeDocument(backendBaseUrl: string): () => void { + const noop = () => {}; + if ( + typeof document === "undefined" || + typeof window === "undefined" || + !document.body + ) { + return noop; + } + + let backend: URL; + try { + backend = new URL(backendBaseUrl); + } catch { + return noop; + } + if ( + backend.protocol !== "https:" || + !backend.hostname.endsWith(".onrender.com") || + backend.origin === window.location.origin || + backend.username || backend.password || backend.port || + backend.pathname !== "/" || backend.search || backend.hash + ) { + return noop; + } + + // A normal browser document can wake a sleeping Render service while + // background fetches receive startup responses. This makes one document + // request, with scripts and navigation disabled. Its load event is never + // treated as readiness: the regular health probes must still confirm JSON. + const frame = document.createElement("iframe"); + frame.hidden = true; + frame.tabIndex = -1; + frame.title = "CalorieApp startup"; + frame.referrerPolicy = "no-referrer"; + frame.setAttribute("aria-hidden", "true"); + frame.setAttribute("sandbox", ""); + frame.src = `${backend.origin}/health`; + try { + document.body.appendChild(frame); + } catch { + frame.remove(); + return noop; + } + return () => frame.remove(); +} + /** * Render free services can take 50 seconds or more to wake after inactivity. * Probe one health route until the backend returns the expected JSON response, @@ -243,6 +290,7 @@ export async function waitForBackendReady( } throwIfAborted(signal); + const removeWakeDocument = mountBackendWakeDocument(normalizedBaseUrl); const directController = new AbortController(); const sameOriginController = new AbortController(); const abortBoth = () => { @@ -272,6 +320,7 @@ export async function waitForBackendReady( throw new BackendRequestTimeoutError(); } finally { abortBoth(); + removeWakeDocument(); signal?.removeEventListener("abort", abortBoth); } } diff --git a/tools/tests/backend_warmup_rate_limit.test.mjs b/tools/tests/backend_warmup_rate_limit.test.mjs index 48d0409..a8032b8 100644 --- a/tools/tests/backend_warmup_rate_limit.test.mjs +++ b/tools/tests/backend_warmup_rate_limit.test.mjs @@ -33,7 +33,7 @@ function response(status, retryAfter = null, contentType = "text/plain") { const healthy = () => response(200, null, "application/json"); -function warmupHarness(responses) { +function warmupHarness(responses, browser = {}) { let now = Date.UTC(2026, 0, 1); const startedAt = now; let nextTimer = 0; @@ -45,10 +45,12 @@ function warmupHarness(responses) { } const context = vm.createContext({ AbortController, + URL, Date: Clock, module, exports: module.exports, process: { env: {} }, + ...browser, setTimeout(callback, delay) { const id = ++nextTimer; timers.set(id, { at: now + delay, callback }); @@ -102,6 +104,91 @@ function warmupHarness(responses) { }; } +function browserDocument() { + const mounted = new Set(); + const navigations = []; + return { + mounted, + navigations, + window: { location: { origin: "https://app.calorietoken.net" } }, + document: { + body: { + appendChild(frame) { + mounted.add(frame); + navigations.push(frame); + }, + }, + createElement(tag) { + assert.equal(tag, "iframe"); + return { + attributes: {}, + setAttribute(name, value) { this.attributes[name] = value; }, + remove() { mounted.delete(this); }, + }; + }, + }, + }; +} + +test("a document request wakes a backend that background fetches cannot wake", async () => { + const browser = browserDocument(); + const harness = warmupHarness(() => + browser.navigations.length && harness.elapsed() >= 45_000 + ? healthy() + : response(429, "0"), browser); + await harness.settle(harness.start(undefined, 180_000, "https://backend.onrender.com")); + assert.equal(browser.navigations.length, 1); + assert.equal(browser.navigations[0].src, "https://backend.onrender.com/health"); + assert.equal(browser.navigations[0].attributes.sandbox, ""); + assert.equal(browser.navigations[0].referrerPolicy, "no-referrer"); + assert.equal(browser.navigations[0].hidden, true); + assert.equal(browser.mounted.size, 0); + assert.equal(harness.elapsed(), 60_000); +}); + +test("a document load alone never counts as a healthy backend", async () => { + const browser = browserDocument(); + const harness = warmupHarness(() => response(429, "0"), browser); + await assert.rejects( + harness.settle(harness.start(undefined, 180_000, "https://backend.onrender.com")), + { name: "BackendRequestTimeoutError" } + ); + assert.equal(browser.navigations.length, 1); + assert.equal(browser.mounted.size, 0); + assert.equal(harness.pendingTimers(), 0); +}); + +test("cancelling startup removes its document and all retry timers", async () => { + const browser = browserDocument(); + const harness = warmupHarness(() => response(429, "120"), browser); + const controller = new AbortController(); + harness.abortAfter(controller, 5_000); + await assert.rejects( + harness.settle(harness.start(controller.signal, 180_000, "https://backend.onrender.com")), + /Login cancelled/ + ); + assert.equal(browser.navigations.length, 1); + assert.equal(browser.mounted.size, 0); + assert.equal(harness.pendingTimers(), 0); +}); + +for (const baseUrl of [ + "/api/backend", + "http://backend.onrender.com", + "https://unrelated.example", + "https://backend.onrender.com.unrelated.example", + "https://user:password@backend.onrender.com", + "https://backend.onrender.com/path", + "https://backend.onrender.com/?query=private", +]) { + test(`no startup document is opened for ${baseUrl}`, async () => { + const browser = browserDocument(); + const harness = warmupHarness(() => healthy(), browser); + await harness.settle(harness.start(undefined, 180_000, baseUrl)); + assert.equal(browser.navigations.length, 0); + }); +} + test("ready backend proceeds immediately without credentials", async () => { const harness = warmupHarness([healthy()]); await harness.settle(harness.start());