220 lines
6.4 KiB
JavaScript
220 lines
6.4 KiB
JavaScript
// @ts-check
|
|
import fs from "node:fs";
|
|
import crypto from "node:crypto";
|
|
import http from "node:http";
|
|
import https from "node:https";
|
|
import pTimeout from "p-timeout";
|
|
import { join, parse, relative } from "node:path";
|
|
import throwErrorIfUnsupported from "./throwErrorIfUnsupported.js";
|
|
import { sync as exists } from "@polymech/fs/exists";
|
|
|
|
function getDefaultImage() {
|
|
const filepath = join(cwd, "public", "images", "default.png");
|
|
const src = join("/", relative(cwd, filepath));
|
|
return { src, base: "default" };
|
|
}
|
|
import {
|
|
cwd,
|
|
fsCachePath,
|
|
supportedImageTypes,
|
|
} from "../../utils/runtimeChecks.js";
|
|
|
|
const { fileTypeFromBuffer } = await import("file-type");
|
|
|
|
const defaults = {
|
|
maxRedirects: 3,
|
|
retries: 3,
|
|
baseDelay: 250,
|
|
maxTotalTime: 5000,
|
|
devTimeout: 10000,
|
|
};
|
|
|
|
class NetworkError extends Error {
|
|
code;
|
|
|
|
constructor(message, code) {
|
|
super(message);
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
function fetchWithRedirects(urlString, options, currentRedirects = 0) {
|
|
|
|
return new Promise((resolve, reject) => {
|
|
if (currentRedirects > options.maxRedirects) {
|
|
return reject(new Error("Too many redirects"));
|
|
}
|
|
|
|
const url = new URL(urlString);
|
|
const get = url.protocol === "https:" ? https.get : http.get;
|
|
|
|
const req = get(url, (response) => {
|
|
if (
|
|
response.statusCode &&
|
|
response.statusCode >= 300 &&
|
|
response.statusCode < 400 &&
|
|
response.headers.location
|
|
) {
|
|
response.resume(); // consume data to free up memory
|
|
const redirectUrl = new URL(response.headers.location, url).toString();
|
|
fetchWithRedirects(redirectUrl, options, currentRedirects + 1)
|
|
.then(resolve)
|
|
.catch(reject);
|
|
} else {
|
|
if (response.statusCode && response.statusCode >= 400) {
|
|
response.resume(); // consume data to free up memory
|
|
return reject(
|
|
new NetworkError(
|
|
`Server responded with status code ${response.statusCode}`,
|
|
"EHTTP"
|
|
)
|
|
);
|
|
}
|
|
|
|
const contentType = response.headers["content-type"];
|
|
if (!contentType || !contentType.startsWith("image/")) {
|
|
response.resume(); // consume data to free up memory
|
|
return reject(
|
|
new NetworkError(
|
|
`Invalid content type: ${contentType}. Expected an image.`,
|
|
"EINVALIDCONTENT"
|
|
)
|
|
);
|
|
}
|
|
|
|
// Mimic Fetch API's Response object
|
|
resolve({
|
|
arrayBuffer: () =>
|
|
new Promise((resolveBody, rejectBody) => {
|
|
const chunks = [];
|
|
response.on("data", (chunk) => chunks.push(chunk));
|
|
response.on("end", () => resolveBody(Buffer.concat(chunks)));
|
|
response.on("error", (err) => rejectBody(err));
|
|
}),
|
|
});
|
|
}
|
|
});
|
|
|
|
req.on("error", (err) => {
|
|
reject(err);
|
|
});
|
|
});
|
|
}
|
|
|
|
// Retry mechanism with exponential backoff
|
|
async function retryWithBackoff(fn, options) {
|
|
const isDev = process.env.NODE_ENV === "development";
|
|
const timeout = isDev ? options.devTimeout : options.maxTotalTime;
|
|
|
|
const promise = (async () => {
|
|
for (let i = 0; i < options.retries; i++) {
|
|
try {
|
|
return await fn();
|
|
} catch (error) {
|
|
if (i === options.retries - 1) {
|
|
throw error; // Last attempt failed
|
|
}
|
|
// Check if it's a file system error that we should retry
|
|
const isRetryableError =
|
|
error.code === "EBUSY" ||
|
|
error.code === "ENOENT" ||
|
|
error.code === "EPERM"
|
|
// error.code === "UNKNOWN" ||
|
|
// error.errno === -4094; // UNKNOWN error on Windows
|
|
|
|
if (!isRetryableError) {
|
|
throw error; // Don't retry non-transient errors
|
|
}
|
|
|
|
const delay = options.baseDelay * Math.pow(2, i); // Exponential backoff
|
|
console.warn(
|
|
`Retry attempt ${i + 1
|
|
}/${options.retries} for file operation after ${delay}ms delay - code: ${error.code}`,
|
|
error.message
|
|
);
|
|
await new Promise((res) => setTimeout(res, delay));
|
|
}
|
|
}
|
|
})();
|
|
|
|
return pTimeout(promise, { milliseconds: timeout });
|
|
}
|
|
|
|
export default async function getResolvedSrc(src, options = {}) {
|
|
const mergedOptions = { ...defaults, ...options };
|
|
|
|
try {
|
|
const token = crypto.createHash("md5").update(src).digest("hex");
|
|
let filepath = fsCachePath + token;
|
|
|
|
const fileExists = await retryWithBackoff(() => {
|
|
for (const type of supportedImageTypes) {
|
|
const fileExists = fs.existsSync(filepath + `.${type}`);
|
|
if (fileExists) {
|
|
filepath += `.${type}`;
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}, mergedOptions);
|
|
|
|
if (!fileExists) {
|
|
const response = await fetchWithRedirects(src, mergedOptions);
|
|
const arrayBuffer = await response.arrayBuffer();
|
|
if (!arrayBuffer || arrayBuffer.byteLength === 0) {
|
|
console.error(`Received an empty image buffer for ${src}. Using default image.`);
|
|
return getDefaultImage();
|
|
}
|
|
const buffer = Buffer.from(arrayBuffer);
|
|
let { ext } = (await fileTypeFromBuffer(buffer)) || {};
|
|
|
|
if (!ext) {
|
|
const url = new URL(src);
|
|
ext = /** @type {import('file-type').FileExtension} */ (
|
|
url.pathname.split(".").pop() || ""
|
|
);
|
|
}
|
|
throwErrorIfUnsupported(src, ext);
|
|
filepath += `.${ext}`;
|
|
// Use retry mechanism for file write operations
|
|
//await retryWithBackoff(async () => {
|
|
// await new Promise((res) => setTimeout(res, 500));
|
|
if (!exists(filepath)) {
|
|
try {
|
|
fs.writeFileSync(filepath, buffer);
|
|
} catch (error) {
|
|
console.error(error);
|
|
throw error;
|
|
}
|
|
} else {
|
|
// console.log("file exists", filepath);
|
|
// const fileBuffer = fs.readFileSync(filepath);
|
|
// if (fileBuffer.equals(buffer)) {
|
|
// return { src: filepath, base: ext };
|
|
// }
|
|
}
|
|
//}, mergedOptions);
|
|
}
|
|
|
|
const base = /^https?:/.test(src)
|
|
? parse(new URL(src).pathname).name
|
|
: undefined;
|
|
|
|
const resolvedSrc = join("/", relative(cwd, filepath));
|
|
|
|
return { src: resolvedSrc, base };
|
|
} catch (error) {
|
|
if (
|
|
error.code === "ENOTFOUND" ||
|
|
error.code === "EHTTP" ||
|
|
error.code === "EINVALIDCONTENT"
|
|
) {
|
|
console.error(
|
|
`Failed to fetch ${src}: ${error.message}. Using default image.`
|
|
);
|
|
return getDefaultImage();
|
|
}
|
|
throw error;
|
|
}
|
|
}
|