astro
Version:
Astro is a modern site builder with web best practices, performance, and DX front-of-mind.
306 lines (305 loc) • 9.52 kB
JavaScript
import picomatch from "picomatch";
import { AstroError } from "../errors/errors.js";
import { CacheQueryConfigConflict } from "../errors/errors-data.js";
function parseCdnCacheControl(header) {
let maxAge = 0;
let swr = 0;
if (!header) return { maxAge, swr };
for (const part of header.split(",")) {
const trimmed = part.trim().toLowerCase();
if (trimmed.startsWith("max-age=")) {
maxAge = Number.parseInt(trimmed.slice(8), 10) || 0;
} else if (trimmed.startsWith("stale-while-revalidate=")) {
swr = Number.parseInt(trimmed.slice(23), 10) || 0;
}
}
return { maxAge, swr };
}
function parseCacheTags(header) {
if (!header) return [];
return header.split(",").map((t) => t.trim()).filter(Boolean);
}
const DEFAULT_EXCLUDED_PARAMS = [
"utm_*",
"fbclid",
"gclid",
"gbraid",
"wbraid",
"dclid",
"msclkid",
"twclid",
"li_fat_id",
"mc_cid",
"mc_eid",
"_ga",
"_gl",
"_hsenc",
"_hsmi",
"_ke",
"oly_anon_id",
"oly_enc_id",
"rb_clickid",
"s_cid",
"vero_id",
"wickedid",
"yclid",
"__s",
"ref"
];
function normalizeQueryConfig(query) {
if (query?.include && query?.exclude) {
throw new AstroError(CacheQueryConfigConflict);
}
const sort = query?.sort !== false;
const include = query?.include ?? null;
const excludePatterns = include ? [] : query?.exclude ?? DEFAULT_EXCLUDED_PARAMS;
const excludeMatcher = excludePatterns.length > 0 ? picomatch(excludePatterns, { nocase: true }) : null;
return { sort, include, excludeMatcher };
}
function buildQueryString(url, config) {
const params = new URLSearchParams(url.searchParams);
if (config.include) {
const allowed = new Set(config.include);
for (const key of [...params.keys()]) {
if (!allowed.has(key)) {
params.delete(key);
}
}
}
if (config.excludeMatcher) {
for (const key of [...params.keys()]) {
if (config.excludeMatcher(key)) {
params.delete(key);
}
}
}
if (config.sort) {
params.sort();
}
const qs = params.toString();
return qs ? `?${qs}` : "";
}
function getCacheKey(url, queryConfig) {
return `${url.origin}${url.pathname}${buildQueryString(url, queryConfig)}`;
}
function getPathFromCacheKey(key, queryConfig) {
const urlPart = key.split("\0")[0];
if (!URL.canParse(urlPart)) return null;
const url = new URL(urlPart);
return `${url.pathname}${buildQueryString(url, queryConfig)}`;
}
const IGNORED_VARY_HEADERS = /* @__PURE__ */ new Set(["cookie", "set-cookie"]);
function parseVaryHeader(response) {
const vary = response.headers.get("Vary");
if (!vary || vary.trim() === "*") return void 0;
const headers = vary.split(",").map((h) => h.trim().toLowerCase()).filter((h) => h && !IGNORED_VARY_HEADERS.has(h));
return headers.length > 0 ? headers : void 0;
}
function getVaryValues(request, varyHeaders) {
const values = /* @__PURE__ */ Object.create(null);
for (const header of varyHeaders) {
values[header] = request.headers.get(header) ?? "";
}
return values;
}
function matchesVary(request, entry) {
if (!entry.vary || !entry.varyValues) return true;
for (const header of entry.vary) {
const requestValue = request.headers.get(header) ?? "";
if (requestValue !== entry.varyValues[header]) return false;
}
return true;
}
function hasSetCookieHeader(response) {
return response.headers.has("set-cookie");
}
function warnSkippedSetCookie(url) {
console.warn(
`[astro:cache] Skipping cache for ${url.pathname}${url.search} because response includes Set-Cookie.`
);
}
class LRUMap {
#map = /* @__PURE__ */ new Map();
#max;
constructor(max) {
this.#max = max;
}
get(key) {
const value = this.#map.get(key);
if (value !== void 0) {
this.#map.delete(key);
this.#map.set(key, value);
}
return value;
}
set(key, value) {
if (this.#map.has(key)) {
this.#map.delete(key);
} else if (this.#map.size >= this.#max) {
const oldest = this.#map.keys().next().value;
this.#map.delete(oldest);
}
this.#map.set(key, value);
}
delete(key) {
return this.#map.delete(key);
}
values() {
return this.#map.values();
}
keys() {
return this.#map.keys();
}
get size() {
return this.#map.size;
}
}
async function serializeResponse(response, request, maxAge, swr, tags) {
const body = await response.arrayBuffer();
const headers = [];
response.headers.forEach((value, key) => {
if (key.toLowerCase() === "set-cookie") return;
headers.push([key, value]);
});
const vary = parseVaryHeader(response);
return {
body,
status: response.status,
headers,
storedAt: Date.now(),
maxAge,
swr,
tags,
vary,
varyValues: vary ? getVaryValues(request, vary) : void 0
};
}
function createResponseFromCacheEntry(entry) {
const headers = new Headers(entry.headers);
return new Response(entry.body.slice(0), {
status: entry.status,
headers
});
}
function isExpired(entry) {
const age = (Date.now() - entry.storedAt) / 1e3;
return age > entry.maxAge;
}
function isStale(entry) {
const age = (Date.now() - entry.storedAt) / 1e3;
return age > entry.maxAge && age <= entry.maxAge + entry.swr;
}
function buildVarySuffix(request, varyHeaders) {
if (varyHeaders.length === 0) return "";
const parts = [];
for (const header of varyHeaders) {
parts.push(`${header}=${request.headers.get(header) ?? ""}`);
}
return `\0${parts.join("\0")}`;
}
const memoryProvider = ((config) => {
const max = config?.max ?? 1e3;
const queryConfig = normalizeQueryConfig(config?.query);
const cache = new LRUMap(max);
const varyMap = /* @__PURE__ */ new Map();
return {
name: "memory",
async onRequest(context, next) {
const requestUrl = new URL(context.request.url);
if (context.request.method !== "GET") {
return next();
}
const primaryKey = getCacheKey(requestUrl, queryConfig);
const knownVary = varyMap.get(primaryKey);
const varySuffix = knownVary ? buildVarySuffix(context.request, knownVary) : "";
const key = primaryKey + varySuffix;
const cached = cache.get(key);
if (cached) {
if (matchesVary(context.request, cached)) {
if (!isExpired(cached)) {
const response2 = createResponseFromCacheEntry(cached);
response2.headers.set("X-Astro-Cache", "HIT");
return response2;
}
if (isStale(cached)) {
next().then(async (freshResponse) => {
const cdnCC2 = freshResponse.headers.get("CDN-Cache-Control");
const { maxAge: newMaxAge, swr: newSwr } = parseCdnCacheControl(cdnCC2);
if (newMaxAge > 0) {
if (hasSetCookieHeader(freshResponse)) {
warnSkippedSetCookie(requestUrl);
return;
}
const newTags = parseCacheTags(freshResponse.headers.get("Cache-Tag"));
const newEntry = await serializeResponse(
freshResponse,
context.request,
newMaxAge,
newSwr,
newTags
);
if (newEntry.vary) {
varyMap.set(primaryKey, newEntry.vary);
}
cache.set(key, newEntry);
}
}).catch((error) => {
console.warn(
`[astro:cache] Background revalidation failed for ${requestUrl.pathname}${requestUrl.search}: ${String(
error
)}`
);
});
const response2 = createResponseFromCacheEntry(cached);
response2.headers.set("X-Astro-Cache", "STALE");
return response2;
}
}
}
const response = await next();
const cdnCC = response.headers.get("CDN-Cache-Control");
const { maxAge, swr } = parseCdnCacheControl(cdnCC);
if (maxAge > 0) {
if (hasSetCookieHeader(response)) {
warnSkippedSetCookie(requestUrl);
return response;
}
const tags = parseCacheTags(response.headers.get("Cache-Tag"));
const [forCache, forClient] = [response.clone(), response];
const entry = await serializeResponse(forCache, context.request, maxAge, swr, tags);
let storeKey = primaryKey;
if (entry.vary) {
varyMap.set(primaryKey, entry.vary);
storeKey = primaryKey + buildVarySuffix(context.request, entry.vary);
}
cache.set(storeKey, entry);
forClient.headers.set("X-Astro-Cache", "MISS");
return forClient;
}
return response;
},
async invalidate(invalidateOptions) {
if (invalidateOptions.path) {
for (const key of [...cache.keys()]) {
if (getPathFromCacheKey(key, queryConfig) === invalidateOptions.path) {
cache.delete(key);
}
}
}
if (invalidateOptions.tags) {
const tagsToInvalidate = Array.isArray(invalidateOptions.tags) ? invalidateOptions.tags : [invalidateOptions.tags];
const tagsSet = new Set(tagsToInvalidate);
for (const key of [...cache.keys()]) {
const entry = cache.get(key);
if (entry && entry.tags.some((t) => tagsSet.has(t))) {
cache.delete(key);
}
}
}
}
};
});
var memory_provider_default = memoryProvider;
export {
memory_provider_default as default
};