Update cache using ETag
This commit is contained in:
parent
00e5cc93c9
commit
0056e30d0c
@ -1,9 +1,19 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { URL } from "node:url";
|
import { URL } from "node:url";
|
||||||
import { getCacheTtl, getCache, setCache } from "./filesystem-cache";
|
import {
|
||||||
|
CACHE_MAX_SIZE,
|
||||||
|
getCacheTtl,
|
||||||
|
getCache,
|
||||||
|
setCache,
|
||||||
|
} from "./filesystem-cache";
|
||||||
|
|
||||||
const ENVELOPE_SENTINEL = "__envelope";
|
const ENVELOPE_SENTINEL = "__envelope";
|
||||||
|
const MEMORY_LIMIT_BYTES = 1024 * 1024 * 256; // MiB
|
||||||
|
export const MAX_CACHE_SIZE_BYTES = Math.min(
|
||||||
|
CACHE_MAX_SIZE,
|
||||||
|
MEMORY_LIMIT_BYTES,
|
||||||
|
);
|
||||||
|
|
||||||
// Extracts the type of the 'method' property from RequestInit
|
// Extracts the type of the 'method' property from RequestInit
|
||||||
type FetchMethod = NonNullable<RequestInit["method"]>;
|
type FetchMethod = NonNullable<RequestInit["method"]>;
|
||||||
@ -41,10 +51,15 @@ function isMetadata(url: string): boolean {
|
|||||||
return secureApi || smallish;
|
return secureApi || smallish;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StoredResponse {
|
||||||
|
isRevivalNeeded: boolean;
|
||||||
|
response: Response;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves a stored Response from the filesystem if available.
|
* Retrieves a stored Response from the filesystem if available.
|
||||||
*/
|
*/
|
||||||
export function getStoredResponse(url: string): Response | undefined {
|
export function getStoredResponse(url: string): StoredResponse | undefined {
|
||||||
if (!isMetadata(url)) {
|
if (!isMetadata(url)) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@ -54,20 +69,41 @@ export function getStoredResponse(url: string): Response | undefined {
|
|||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(data);
|
const parsed = JSON.parse(data);
|
||||||
|
|
||||||
if (
|
if (parsed && "object" === typeof parsed && ENVELOPE_SENTINEL in parsed) {
|
||||||
parsed &&
|
if (
|
||||||
"object" === typeof parsed &&
|
parsed[ENVELOPE_SENTINEL] !==
|
||||||
ENVELOPE_SENTINEL in parsed &&
|
|
||||||
parsed[ENVELOPE_SENTINEL] ===
|
|
||||||
makeEnvelopeValue(parsed.method, url, parsed.status)
|
makeEnvelopeValue(parsed.method, url, parsed.status)
|
||||||
) {
|
) {
|
||||||
return new Response(parsed.body, {
|
return undefined;
|
||||||
status: parsed.status,
|
}
|
||||||
headers: {
|
|
||||||
...parsed.headers,
|
const response = new Response(
|
||||||
"X-Storage-Hit": "true",
|
"base64" === parsed.encoding
|
||||||
|
? Buffer.from(parsed.body, "base64")
|
||||||
|
: parsed.body,
|
||||||
|
{
|
||||||
|
status: parsed.status,
|
||||||
|
headers: { ...parsed.headers, "X-Storage-Hit": "true" },
|
||||||
},
|
},
|
||||||
});
|
);
|
||||||
|
|
||||||
|
const fetchedTime =
|
||||||
|
"number" === typeof parsed.storedAt
|
||||||
|
? parsed.storedAt
|
||||||
|
: new Date(response.headers.get("Date") || 0).getTime();
|
||||||
|
|
||||||
|
let isRevivalNeeded = false;
|
||||||
|
if (!isNaN(fetchedTime)) {
|
||||||
|
const age = Date.now() - fetchedTime;
|
||||||
|
const percentTTL = (getCacheTtl() / 100) * 25;
|
||||||
|
// If the data is older than 25% of its TTL, signal for revival
|
||||||
|
isRevivalNeeded = age > percentTTL;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isRevivalNeeded: isRevivalNeeded,
|
||||||
|
response: response,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* Not JSON or not an envelope; Fall through to legacy handler */
|
/* Not JSON or not an envelope; Fall through to legacy handler */
|
||||||
@ -81,15 +117,19 @@ export function getStoredResponse(url: string): Response | undefined {
|
|||||||
// Legacy/Raw Handler: Synthetic Last-Modified
|
// Legacy/Raw Handler: Synthetic Last-Modified
|
||||||
// (now - TTL) is the oldest possible age for this data
|
// (now - TTL) is the oldest possible age for this data
|
||||||
const lastModified = new Date(Date.now() - getCacheTtl()).toUTCString();
|
const lastModified = new Date(Date.now() - getCacheTtl()).toUTCString();
|
||||||
return new Response(data, {
|
return {
|
||||||
status: 200,
|
isRevivalNeeded: false,
|
||||||
headers: {
|
response: new Response(data, {
|
||||||
"Content-Type": contentType,
|
status: 200,
|
||||||
"Last-Modified": lastModified,
|
headers: {
|
||||||
"X-Storage-Hit": "true",
|
"Content-Type": contentType,
|
||||||
},
|
"Last-Modified": lastModified,
|
||||||
});
|
"X-Storage-Hit": "true",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -101,13 +141,49 @@ export async function setStoredResponse(
|
|||||||
res: Response,
|
res: Response,
|
||||||
method: FetchMethod = "GET",
|
method: FetchMethod = "GET",
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!isMetadata(url) || !res.ok) {
|
if (
|
||||||
|
"GET" !== method.toUpperCase() ||
|
||||||
|
!isMetadata(url) ||
|
||||||
|
!res.ok ||
|
||||||
|
res.bodyUsed
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentLength = parseInt(res.headers.get("content-length") || "0", 10);
|
||||||
|
if (contentLength > MAX_CACHE_SIZE_BYTES) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// We clone so the original stream remains readable by the caller
|
// We clone so the original stream remains readable by the caller
|
||||||
const body = await res.clone().text();
|
const clonedRes = res.clone();
|
||||||
|
const azureMd5 = res.headers.get("x-ms-blob-content-md5");
|
||||||
|
|
||||||
|
let body: string;
|
||||||
|
let encoding: "utf8" | "base64";
|
||||||
|
|
||||||
|
if (azureMd5) {
|
||||||
|
encoding = "base64";
|
||||||
|
|
||||||
|
// Trust the server: MD5 presence implies binary-safe path is needed
|
||||||
|
const buffer = await clonedRes.arrayBuffer();
|
||||||
|
if (buffer.byteLength > MAX_CACHE_SIZE_BYTES) return;
|
||||||
|
|
||||||
|
const bodyBuffer = Buffer.from(buffer);
|
||||||
|
const hash = createHash("md5").update(bodyBuffer).digest("base64");
|
||||||
|
|
||||||
|
if (hash !== azureMd5) return;
|
||||||
|
|
||||||
|
body = bodyBuffer.toString(encoding);
|
||||||
|
} else {
|
||||||
|
encoding = "utf8";
|
||||||
|
|
||||||
|
// No MD5: Default to efficient text path
|
||||||
|
body = await clonedRes.text();
|
||||||
|
if (Buffer.byteLength(body, "utf8") > MAX_CACHE_SIZE_BYTES) return;
|
||||||
|
}
|
||||||
|
|
||||||
const headers = Object.fromEntries(res.headers.entries());
|
const headers = Object.fromEntries(res.headers.entries());
|
||||||
const envelope = JSON.stringify({
|
const envelope = JSON.stringify({
|
||||||
[ENVELOPE_SENTINEL]: makeEnvelopeValue(method, url, res.status),
|
[ENVELOPE_SENTINEL]: makeEnvelopeValue(method, url, res.status),
|
||||||
@ -118,7 +194,10 @@ export async function setStoredResponse(
|
|||||||
status: res.status,
|
status: res.status,
|
||||||
statusText: res.statusText,
|
statusText: res.statusText,
|
||||||
url: res.url,
|
url: res.url,
|
||||||
|
encoding: encoding,
|
||||||
|
storedAt: res.storedAt ?? Date.now(), // for testing
|
||||||
});
|
});
|
||||||
|
|
||||||
setCache(url, envelope);
|
setCache(url, envelope);
|
||||||
} catch {
|
} catch {
|
||||||
// Fail silently to avoid breaking the main execution flow
|
// Fail silently to avoid breaking the main execution flow
|
||||||
|
|||||||
28
src/utils.ts
28
src/utils.ts
@ -44,11 +44,23 @@ export async function request(
|
|||||||
headers.set("User-Agent", "@oven-sh/setup-bun");
|
headers.set("User-Agent", "@oven-sh/setup-bun");
|
||||||
}
|
}
|
||||||
|
|
||||||
const canUseResponseCache = "GET" === (init?.method ?? "GET").toUpperCase();
|
const method = (init?.method ?? "GET").toUpperCase();
|
||||||
|
const canUseResponseCache = "GET" === method;
|
||||||
|
const stored = getStoredResponse(url);
|
||||||
if (canUseResponseCache) {
|
if (canUseResponseCache) {
|
||||||
const stored = getStoredResponse(url);
|
|
||||||
if (stored) {
|
if (stored) {
|
||||||
return stored;
|
if (stored.isRevivalNeeded) {
|
||||||
|
const etag = stored.response.headers.get("ETag");
|
||||||
|
if (etag) {
|
||||||
|
headers.set("If-None-Match", etag);
|
||||||
|
}
|
||||||
|
const lastModified = stored.response.headers.get("Last-Modified");
|
||||||
|
if (lastModified) {
|
||||||
|
headers.set("If-Modified-Since", lastModified);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return stored.response;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -56,6 +68,14 @@ export async function request(
|
|||||||
...init,
|
...init,
|
||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (304 === res.status && canUseResponseCache && stored) {
|
||||||
|
// We re-save the cached response to update the modification time
|
||||||
|
// to 'now' effectively resetting the TTL.
|
||||||
|
await setStoredResponse(url, stored.response, method);
|
||||||
|
return stored.response;
|
||||||
|
}
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const body = await res.text().catch(() => "");
|
const body = await res.text().catch(() => "");
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@ -64,7 +84,7 @@ export async function request(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (canUseResponseCache) {
|
if (canUseResponseCache) {
|
||||||
await setStoredResponse(url, res, (init?.method ?? "GET").toUpperCase());
|
await setStoredResponse(url, res, method);
|
||||||
}
|
}
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user