Files
beall-11ty/node_modules/@11ty/eleventy-fetch/src/AssetCache.js
2026-03-31 16:38:22 -07:00

271 lines
6.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const fs = require("node:fs");
const path = require("node:path");
const { DateCompare, createHashHexSync } = require("@11ty/eleventy-utils");
const FileCache = require("./FileCache.js");
const Sources = require("./Sources.js");
const debugUtil = require("debug");
const debug = debugUtil("Eleventy:Fetch");
class AssetCache {
#source;
#hash;
#customFilename;
#cache;
#cacheDirectory;
#cacheLocationDirty = false;
#directoryManager;
constructor(source, cacheDirectory, options = {}) {
if(!Sources.isValidSource(source)) {
throw Sources.getInvalidSourceError(source);
}
let uniqueKey = AssetCache.getCacheKey(source, options);
this.uniqueKey = uniqueKey;
this.hash = AssetCache.getHash(uniqueKey, options.hashLength);
this.cacheDirectory = cacheDirectory || ".cache";
this.options = options;
this.defaultDuration = "1d";
this.duration = options.duration || this.defaultDuration;
// Compute the filename only once
if (typeof this.options.filenameFormat === "function") {
this.#customFilename = AssetCache.cleanFilename(this.options.filenameFormat(uniqueKey, this.hash));
if (typeof this.#customFilename !== "string" || this.#customFilename.length === 0) {
throw new Error(`The provided filenameFormat callback function needs to return valid filename characters.`);
}
}
}
log(message) {
if (this.options.verbose) {
console.log(`[11ty/eleventy-fetch] ${message}`);
} else {
debug(message);
}
}
static cleanFilename(filename) {
// Ensure no illegal characters are present (Windows or Linux: forward/backslash, chevrons, colon, double-quote, pipe, question mark, asterisk)
if (filename.match(/([\/\\<>:"|?*]+?)/)) {
let sanitizedFilename = filename.replace(/[\/\\<>:"|?*]+/g, "");
debug(
`[@11ty/eleventy-fetch] Some illegal characters were removed from the cache filename: ${filename} will be cached as ${sanitizedFilename}.`,
);
return sanitizedFilename;
}
return filename;
}
static getCacheKey(source, options) {
// RemoteAssetCache passes in a string here, which skips this check (requestId is already used upstream)
if (Sources.isValidComplexSource(source)) {
if(options.requestId) {
return options.requestId;
}
if(typeof source.toString === "function") {
// return source.toString();
let toStr = source.toString();
if(toStr !== "function() {}" && toStr !== "[object Object]") {
return toStr;
}
}
throw Sources.getInvalidSourceError(source);
}
return source;
}
// Defult hashLength also set in global options, duplicated here for tests
// v5.0+ key can be Array or literal
static getHash(key, hashLength = 30) {
if (!Array.isArray(key)) {
key = [key];
}
let result = createHashHexSync(...key);
return result.slice(0, hashLength);
}
get source() {
return this.#source;
}
set source(source) {
this.#source = source;
}
get hash() {
return this.#hash;
}
set hash(value) {
if (value !== this.#hash) {
this.#cacheLocationDirty = true;
}
this.#hash = value;
}
get cacheDirectory() {
return this.#cacheDirectory;
}
set cacheDirectory(dir) {
if (dir !== this.#cacheDirectory) {
this.#cacheLocationDirty = true;
}
this.#cacheDirectory = dir;
}
get cacheFilename() {
if (typeof this.#customFilename === "string" && this.#customFilename.length > 0) {
return this.#customFilename;
}
return `eleventy-fetch-${this.hash}`;
}
get rootDir() {
// Work in an AWS Lambda (serverless)
// https://docs.aws.amazon.com/lambda/latest/dg/configuration-envvars.html
// Bad: LAMBDA_TASK_ROOT is /var/task/ on AWS so we must use ELEVENTY_ROOT
// When using ELEVENTY_ROOT, cacheDirectory must be relative
// (we are bundling the cache files into the serverless function)
if (
process.env.LAMBDA_TASK_ROOT &&
process.env.ELEVENTY_ROOT &&
!this.cacheDirectory.startsWith("/")
) {
return path.resolve(process.env.ELEVENTY_ROOT, this.cacheDirectory);
}
// otherwise, it is recommended to use somewhere in /tmp/ for serverless (otherwise it wont write)
return path.resolve(this.cacheDirectory);
}
get cachePath() {
return path.join(this.rootDir, this.cacheFilename);
}
get cache() {
if (!this.#cache || this.#cacheLocationDirty) {
let cache = new FileCache(this.cacheFilename, {
dir: this.rootDir,
source: this.source,
});
cache.setDefaultType(this.options.type);
cache.setDryRun(this.options.dryRun);
cache.setDirectoryManager(this.#directoryManager);
this.#cache = cache;
this.#cacheLocationDirty = false;
}
return this.#cache;
}
getDurationMs(duration = "0s") {
return DateCompare.getDurationMs(duration);
}
setDirectoryManager(manager) {
this.#directoryManager = manager;
}
async save(contents, type = "buffer", metadata = {}) {
if(!contents) {
throw new Error("save(contents) expects contents (was falsy)");
}
this.cache.set(type, contents, metadata);
// Dry-run handled downstream
this.cache.save();
}
getCachedContents() {
return this.cache.getContents();
}
getCachedValue() {
if(this.options.returnType === "response") {
return {
...this.cachedObject.metadata?.response,
body: this.getCachedContents(),
cache: "hit",
}
}
return this.getCachedContents();
}
getCachedTimestamp() {
return this.cachedObject?.cachedAt;
}
isCacheValid(duration = this.duration) {
if(!this.cachedObject || !this.cachedObject?.cachedAt) {
return false;
}
if(this.cachedObject?.type && DateCompare.isTimestampWithinDuration(this.cachedObject?.cachedAt, duration)) {
return this.cache.hasContents(this.cachedObject?.type); // check file system to make files havent been purged.
}
return false;
}
get cachedObject() {
return this.cache.get();
}
// Deprecated
needsToFetch(duration) {
return !this.isCacheValid(duration);
}
// This is only included for completenes—not on the docs.
async fetch(optionsOverride = {}) {
if (this.isCacheValid(optionsOverride.duration)) {
// promise
debug(`Using cached version of: ${this.uniqueKey}`);
return this.getCachedValue();
}
debug(`Saving ${this.uniqueKey} to ${this.cacheFilename}`);
await this.save(this.source, optionsOverride.type);
return this.source;
}
// for testing
hasAnyCacheFiles() {
for(let p of this.cache.getAllPossibleFilePaths()) {
if(fs.existsSync(p)) {
return true;
}
}
return false;
}
// for testing
async destroy() {
await Promise.all(this.cache.getAllPossibleFilePaths().map(path => {
if (fs.existsSync(path)) {
return fs.unlinkSync(path);
}
}))
}
}
module.exports = AssetCache;