Files
ladybird/Tests/LibWeb/Text/input/Cache/http-disk-cache.html
Timothy Flynn add8402536 LibHTTP+RequestServer: Implement the stale-while-revalidate directive
This directive allows our disk cache to serve stale responses for a time
indicated by the directive itself, while we revalidate the response in
the background.

Issuing requests that weren't initiated by a client is a new thing for
RequestServer. In this implementation, we associate the request with
the client that initiated the request to the stale cache entry. This
adds a "background request" mode to the Request object, to prevent us
from trying to send any of the revalidation response over IPC.
2025-12-13 13:07:02 -06:00

434 lines
16 KiB
HTML

<!DOCTYPE html>
<script src="../include.js"></script>
<script>
const TEST_CACHE_ENABLED_HEADER = "X-Ladybird-Enable-Disk-Cache";
const TEST_CACHE_STATUS_HEADER = "X-Ladybird-Disk-Cache-Status";
const TEST_CACHE_REVALIDATION_STATUS_HEADER = "X-Ladybird-Revalidation-Status";
const TEST_CACHE_REQUEST_TIME_OFFSET = "X-Ladybird-Request-Time-Offset";
const TEST_CACHE_RESPOND_WITH_NOT_MODIFIED = "X-Ladybird-Respond-With-Not-Modified";
const server = httpTestServer();
let anyTestFailed = false;
let lastTestPath = null;
async function createRequest(path, options) {
lastTestPath = path;
if (typeof options === "undefined") {
options = {};
}
if (!options.method) {
options.method = "GET";
}
if (!options.status) {
options.status = 200;
}
if (!options.headers) {
options.headers = {};
}
await server.createEcho("OPTIONS", path, {
status: 200,
headers: {
"Access-Control-Allow-Headers": `${TEST_CACHE_ENABLED_HEADER}, ${TEST_CACHE_REQUEST_TIME_OFFSET}, ${TEST_CACHE_RESPOND_WITH_NOT_MODIFIED}`,
"Access-Control-Allow-Methods": options.method,
"Access-Control-Allow-Origin": location.origin,
},
});
options.headers["Access-Control-Allow-Origin"] = location.origin;
options.headers["Access-Control-Expose-Headers"] =
`${TEST_CACHE_STATUS_HEADER}, ${TEST_CACHE_REVALIDATION_STATUS_HEADER}`;
return server.createEcho(options.method, path, {
status: options.status,
headers: options.headers,
reflect_headers_in_body: true,
});
}
async function cacheFetch(url, options) {
if (typeof options === "undefined") {
options = {};
}
if (!options.method) {
options.method = "GET";
}
if (!options.headers) {
options.headers = {};
}
options.headers[TEST_CACHE_ENABLED_HEADER] = "1";
return fetch(url, {
method: options.method,
headers: options.headers,
mode: "cors",
});
}
function expectCacheStatus(url, response, status) {
const result = response.headers.get(TEST_CACHE_STATUS_HEADER);
if (result !== status) {
println(`Expected ${url} to contain a cache status of '${status}': received: '${result}'`);
anyTestFailed = true;
}
}
function expectRevalidationStatus(url, response, status) {
const result = response.headers.get(TEST_CACHE_REVALIDATION_STATUS_HEADER);
if (result !== status) {
println(`Expected ${url} to contain a revalidation status of '${status}': received: '${result}'`);
anyTestFailed = true;
}
}
async function runTests() {
let url, response;
// Non-GET/HEAD requests are not cached.
await (async () => {
for (const method of ["POST", "PUT", "DELETE"]) {
url = await createRequest(`/cache-test/${method.toLowerCase()}`, {
method: method,
headers: {
"Cache-Control": "max-age=999",
},
});
response = await cacheFetch(url, { method });
expectCacheStatus(url, response, "not-cached");
}
})();
// Responses without a Cache-Control or Expires header are not cached.
await (async () => {
url = await createRequest("/cache-test/missing-headers");
response = await cacheFetch(url);
expectCacheStatus(url, response, "not-cached");
})();
// Responses with a no-store directive are not cached.
await (async () => {
url = await createRequest("/cache-test/no-store", {
headers: {
"Cache-Control": "no-store",
},
});
response = await cacheFetch(url);
expectCacheStatus(url, response, "not-cached");
})();
// Responses with only a max-age directive are cached.
await (async () => {
url = await createRequest("/cache-test/max-age", {
headers: {
"Cache-Control": "max-age=5",
},
});
response = await cacheFetch(url);
expectCacheStatus(url, response, "written-to-cache");
response = await cacheFetch(url);
expectCacheStatus(url, response, "read-from-cache");
})();
// Responses with an age less than their max-age directive are cached.
await (async () => {
url = await createRequest("/cache-test/max-age-fresh", {
headers: {
Age: "2",
"Cache-Control": "max-age=5",
},
});
response = await cacheFetch(url);
expectCacheStatus(url, response, "written-to-cache");
response = await cacheFetch(url);
expectCacheStatus(url, response, "read-from-cache");
})();
// Responses with an age equal to their max-age directive are not cached.
await (async () => {
url = await createRequest("/cache-test/max-age-expired", {
headers: {
Age: "5",
"Cache-Control": "max-age=5",
},
});
response = await cacheFetch(url);
expectCacheStatus(url, response, "not-cached");
})();
// Expired responses are cached until their max-age directive is reached.
await (async () => {
url = await createRequest("/cache-test/expired-and-refreshed", {
headers: {
Age: "2",
"Cache-Control": "max-age=5",
},
});
response = await cacheFetch(url);
expectCacheStatus(url, response, "written-to-cache");
response = await cacheFetch(url);
expectCacheStatus(url, response, "read-from-cache");
response = await cacheFetch(url, {
headers: {
[TEST_CACHE_REQUEST_TIME_OFFSET]: "5",
},
});
expectCacheStatus(url, response, "written-to-cache");
})();
// Expired responses are cached until their max-age directive is reached. A must-revalidate cache directive
// without a revalidation attribute results in the cache being refreshed.
await (async () => {
url = await createRequest("/cache-test/expired-and-refreshed-due-to-missing-revalidation-attributes", {
headers: {
Age: "2",
"Cache-Control": "max-age=5,must-revalidate",
},
});
response = await cacheFetch(url);
expectCacheStatus(url, response, "written-to-cache");
response = await cacheFetch(url);
expectCacheStatus(url, response, "read-from-cache");
response = await cacheFetch(url, {
headers: {
[TEST_CACHE_REQUEST_TIME_OFFSET]: "5",
},
});
expectCacheStatus(url, response, "written-to-cache");
})();
// Expired responses are cached until their max-age directive is reached. A must-revalidate cache directive
// with an invalid revalidation attribute results in the cache being refreshed.
await (async () => {
url = await createRequest("/cache-test/expired-and-refreshed-due-to-invalid-revalidation-attributes", {
headers: {
Age: "2",
"Cache-Control": "max-age=5,must-revalidate",
"Last-Modified": new Date().toUTCString(),
},
});
response = await cacheFetch(url);
expectCacheStatus(url, response, "written-to-cache");
response = await cacheFetch(url);
expectCacheStatus(url, response, "read-from-cache");
// By not attaching a TEST_CACHE_RESPOND_WITH_NOT_MODIFIED header, we tell http-test-server to respond to
// revalidation requests with an HTTP 200 (i.e. revalidation fails).
response = await cacheFetch(url, {
headers: {
[TEST_CACHE_REQUEST_TIME_OFFSET]: "5",
},
});
expectCacheStatus(url, response, "written-to-cache");
})();
// Expired responses are cached until their max-age directive is reached. A must-revalidate cache directive
// with a valid revalidation attribute results in the cache being revalidated.
await (async () => {
url = await createRequest("/cache-test/expired-and-revalidated", {
headers: {
Age: "2",
"Cache-Control": "max-age=5,must-revalidate",
"Last-Modified": new Date().toUTCString(),
},
});
response = await cacheFetch(url);
expectCacheStatus(url, response, "written-to-cache");
response = await cacheFetch(url);
expectCacheStatus(url, response, "read-from-cache");
response = await cacheFetch(url, {
headers: {
[TEST_CACHE_REQUEST_TIME_OFFSET]: "5",
[TEST_CACHE_RESPOND_WITH_NOT_MODIFIED]: "1",
},
});
expectCacheStatus(url, response, "read-from-cache");
})();
// Responses with a no-cache directive must always be revalidated.
await (async () => {
url = await createRequest("/cache-test/no-cache", {
headers: {
"Cache-Control": "no-cache",
"Last-Modified": new Date().toUTCString(),
},
});
response = await cacheFetch(url);
expectCacheStatus(url, response, "written-to-cache");
response = await cacheFetch(url, {
headers: {
[TEST_CACHE_RESPOND_WITH_NOT_MODIFIED]: "1",
},
});
expectCacheStatus(url, response, "read-from-cache");
// By not attaching a TEST_CACHE_RESPOND_WITH_NOT_MODIFIED header, we tell http-test-server to respond to
// revalidation requests with an HTTP 200 (i.e. revalidation fails).
response = await cacheFetch(url);
expectCacheStatus(url, response, "written-to-cache");
})();
// Responses without a Cache-Control header may be heuristically cached based on the Last-Modified header.
await (async () => {
// Our current heuristic is 10% of the time since the Last-Modified header.
url = await createRequest("/cache-test/cache-heuristic", {
headers: {
"Last-Modified": new Date(Date.now() - 10 * 60 * 60 * 1000).toUTCString(),
},
});
response = await cacheFetch(url);
expectCacheStatus(url, response, "written-to-cache");
response = await cacheFetch(url);
expectCacheStatus(url, response, "read-from-cache");
response = await cacheFetch(url, {
headers: {
[TEST_CACHE_REQUEST_TIME_OFFSET]: 30 * 60,
},
});
expectCacheStatus(url, response, "read-from-cache");
response = await cacheFetch(url, {
headers: {
[TEST_CACHE_REQUEST_TIME_OFFSET]: 90 * 60,
},
});
expectCacheStatus(url, response, "written-to-cache");
})();
// Expired responses are cached until their max-age and stale-while-revalidate directives are reached. The
// response is successfully revalidated in the background.
await (async () => {
url = await createRequest("/cache-test/stale-while-revalidate-fresh", {
headers: {
"Cache-Control": "max-age=10,stale-while-revalidate=10",
"Last-Modified": new Date().toUTCString(),
},
});
response = await cacheFetch(url);
expectCacheStatus(url, response, "written-to-cache");
response = await cacheFetch(url);
expectCacheStatus(url, response, "read-from-cache");
response = await cacheFetch(url, {
headers: {
[TEST_CACHE_REQUEST_TIME_OFFSET]: 12,
[TEST_CACHE_RESPOND_WITH_NOT_MODIFIED]: "1",
},
});
expectCacheStatus(url, response, "read-from-cache");
expectRevalidationStatus(url, response, null);
// We must issue another request to inspect the status of the background revalidation request triggered by
// the previous request.
response = await cacheFetch(url, {
headers: {
[TEST_CACHE_REQUEST_TIME_OFFSET]: 15,
[TEST_CACHE_RESPOND_WITH_NOT_MODIFIED]: "1",
},
});
expectCacheStatus(url, response, "read-from-cache");
expectRevalidationStatus(url, response, "fresh");
response = await cacheFetch(url, {
headers: {
[TEST_CACHE_REQUEST_TIME_OFFSET]: 60,
},
});
expectCacheStatus(url, response, "written-to-cache");
})();
// Expired responses are cached until their max-age and stale-while-revalidate directives are reached. The
// response is unsuccessfully revalidated in the background.
await (async () => {
url = await createRequest("/cache-test/stale-while-revalidate-expired", {
headers: {
"Cache-Control": "max-age=10,stale-while-revalidate=10",
"Last-Modified": new Date().toUTCString(),
},
});
response = await cacheFetch(url);
expectCacheStatus(url, response, "written-to-cache");
response = await cacheFetch(url);
expectCacheStatus(url, response, "read-from-cache");
// By not attaching a TEST_CACHE_RESPOND_WITH_NOT_MODIFIED header, we tell http-test-server to respond to
// revalidation requests with an HTTP 200 (i.e. revalidation fails).
response = await cacheFetch(url, {
headers: {
[TEST_CACHE_REQUEST_TIME_OFFSET]: 12,
},
});
expectCacheStatus(url, response, "read-from-cache");
// We must issue another request to inspect the status of the background revalidation request triggered by
// the previous request. Even though revalidation failed, the revalidation request itself will have written
// a fresh response to disk, thus this request is served from cache.
response = await cacheFetch(url, {
headers: {
[TEST_CACHE_REQUEST_TIME_OFFSET]: 15,
},
});
expectCacheStatus(url, response, "read-from-cache");
expectRevalidationStatus(url, response, "expired");
response = await cacheFetch(url, {
headers: {
[TEST_CACHE_REQUEST_TIME_OFFSET]: 60,
},
});
expectCacheStatus(url, response, "written-to-cache");
})();
}
asyncTest(async done => {
// Disable memory cache to ensure all requests reach RequestServer.
const httpMemoryCacheWasEnabled = internals.setHttpMemoryCacheEnabled(false);
runTests()
.then(() => {
if (!anyTestFailed) {
println("PASS!");
}
})
.catch(e => {
println(`Caught exception: ${lastTestPath}: ${e}`);
})
.finally(() => {
internals.setHttpMemoryCacheEnabled(httpMemoryCacheWasEnabled);
done();
});
});
</script>