109 lines
4.0 KiB
TypeScript
109 lines
4.0 KiB
TypeScript
/**
|
|
* Fetches product images for invoice line items and returns them as
|
|
* `data:` URLs ready to embed in the PDF.
|
|
*
|
|
* Uses a simple in-process LRU-ish Map keyed by URL. Images are typically
|
|
* served from Shopify's CDN so re-fetching is cheap, but caching avoids
|
|
* hammering the network when regenerating an invoice multiple times.
|
|
*/
|
|
import { safeFetch, SafeFetchError, SHOPIFY_CDN_HOSTS } from "./safeFetch.server";
|
|
import pLimit from "p-limit";
|
|
|
|
const MAX_BYTES = 2 * 1024 * 1024; // 2 MB cap per image
|
|
const CACHE_MAX_ENTRIES = 200;
|
|
/** Max images fetched/embedded per invoice (DoS bound for large carts). */
|
|
const MAX_IMAGES_PER_INVOICE = 100;
|
|
/** Max concurrent image fetches per invoice. */
|
|
const IMAGE_FETCH_CONCURRENCY = 6;
|
|
|
|
const cache = new Map<string, string>(); // url -> data URL
|
|
|
|
function rememberInCache(url: string, dataUrl: string) {
|
|
if (cache.size >= CACHE_MAX_ENTRIES) {
|
|
// Drop the oldest entry (Map preserves insertion order).
|
|
const oldest = cache.keys().next().value;
|
|
if (oldest) cache.delete(oldest);
|
|
}
|
|
cache.set(url, dataUrl);
|
|
}
|
|
|
|
function guessContentType(url: string, headerCt: string | null): string {
|
|
if (headerCt && headerCt.startsWith("image/")) return headerCt;
|
|
const lower = url.toLowerCase();
|
|
if (lower.includes(".jpg") || lower.includes(".jpeg")) return "image/jpeg";
|
|
if (lower.includes(".webp")) return "image/webp";
|
|
if (lower.includes(".gif")) return "image/gif";
|
|
return "image/png";
|
|
}
|
|
|
|
export async function fetchProductImageDataUrl(url: string): Promise<string | undefined> {
|
|
if (!url) return undefined;
|
|
const hit = cache.get(url);
|
|
if (hit) return hit;
|
|
|
|
// Request a small Shopify CDN variant when possible to keep the PDF lean.
|
|
// Shopify image URLs accept a `width=` query param; fall back to the original URL.
|
|
const requestUrl = url.includes("cdn.shopify.com") && !/[?&](width|height|crop)=/.test(url)
|
|
? `${url}${url.includes("?") ? "&" : "?"}width=128`
|
|
: url;
|
|
|
|
let res: Awaited<ReturnType<typeof safeFetch>>;
|
|
try {
|
|
res = await safeFetch(requestUrl, {
|
|
maxBytes: MAX_BYTES,
|
|
accept: "image/*",
|
|
// Lock product images to Shopify's CDN — line item image URLs come
|
|
// from the Admin API and should never point anywhere else.
|
|
allowedHosts: SHOPIFY_CDN_HOSTS,
|
|
});
|
|
} catch (err) {
|
|
if (err instanceof SafeFetchError) {
|
|
console.warn(`Product image refused (${err.code}) for ${url}: ${err.message}`);
|
|
} else {
|
|
console.warn(`Product image fetch failed for ${url}:`, err);
|
|
}
|
|
return undefined;
|
|
}
|
|
if (res.status < 200 || res.status >= 300) {
|
|
console.warn(`Product image HTTP ${res.status} for ${url}`);
|
|
return undefined;
|
|
}
|
|
if (res.bytesRead === 0) return undefined;
|
|
|
|
const contentType = guessContentType(url, res.contentType);
|
|
// @react-pdf supports png/jpeg natively; webp/gif are unreliable. Skip those.
|
|
if (contentType !== "image/png" && contentType !== "image/jpeg") return undefined;
|
|
|
|
const b64 = Buffer.from(res.bytes).toString("base64");
|
|
const dataUrl = `data:${contentType};base64,${b64}`;
|
|
rememberInCache(url, dataUrl);
|
|
return dataUrl;
|
|
}
|
|
|
|
/**
|
|
* Resolves images for every line, mutating `imageDataUrl` in place. Fetches
|
|
* run with bounded concurrency and a hard cap on the number of images
|
|
* embedded per invoice, so a large cart (Shopify allows hundreds of line
|
|
* items) can't trigger an unbounded fan-out of network requests. Failures are
|
|
* swallowed (the row simply renders without an icon).
|
|
*/
|
|
export async function attachLineItemImages(
|
|
lines: { imageUrl?: string; imageDataUrl?: string }[],
|
|
): Promise<void> {
|
|
const limit = pLimit(IMAGE_FETCH_CONCURRENCY);
|
|
let budget = MAX_IMAGES_PER_INVOICE;
|
|
const tasks: Promise<void>[] = [];
|
|
for (const line of lines) {
|
|
if (!line.imageUrl) continue;
|
|
if (budget <= 0) break; // cap reached — remaining rows render iconless
|
|
budget -= 1;
|
|
tasks.push(
|
|
limit(async () => {
|
|
const dataUrl = await fetchProductImageDataUrl(line.imageUrl!);
|
|
if (dataUrl) line.imageDataUrl = dataUrl;
|
|
}),
|
|
);
|
|
}
|
|
await Promise.all(tasks);
|
|
}
|