271 lines
6.5 KiB
JavaScript
271 lines
6.5 KiB
JavaScript
|
|
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 won’t 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 haven’t 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;
|