// @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; } }