Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions frontend/lib/backendRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -243,6 +290,7 @@ export async function waitForBackendReady(
}

throwIfAborted(signal);
const removeWakeDocument = mountBackendWakeDocument(normalizedBaseUrl);
const directController = new AbortController();
const sameOriginController = new AbortController();
const abortBoth = () => {
Expand Down Expand Up @@ -272,6 +320,7 @@ export async function waitForBackendReady(
throw new BackendRequestTimeoutError();
} finally {
abortBoth();
removeWakeDocument();
signal?.removeEventListener("abort", abortBoth);
}
}
Expand Down
89 changes: 88 additions & 1 deletion tools/tests/backend_warmup_rate_limit.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 });
Expand Down Expand Up @@ -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());
Expand Down