fix: unfurl shortcode concurrency control and failure caching

- Increase timeout from 10s to 20s
- Cache failures for 1 day (avoids retrying every build)
- Add concurrency limiter (max 5 parallel requests)
- Refactor into renderCard/renderFallbackLink helpers
This commit is contained in:
Ricardo
2026-02-20 12:20:28 +01:00
parent 9ab4ebb84a
commit e0cbf8121e

View File

@@ -5,6 +5,29 @@ import { createHash } from "crypto";
const CACHE_DIR = resolve(import.meta.dirname, "..", ".cache", "unfurl");
const CACHE_DURATION_MS = 7 * 24 * 60 * 60 * 1000; // 1 week
const FAILURE_CACHE_MS = 24 * 60 * 60 * 1000; // 1 day for failed fetches
// Concurrency limiter — prevents overwhelming outbound network
let activeRequests = 0;
const MAX_CONCURRENT = 5;
const queue = [];
function runNext() {
if (queue.length === 0 || activeRequests >= MAX_CONCURRENT) return;
activeRequests++;
const { resolve: res, fn } = queue.shift();
fn().then(res).finally(() => {
activeRequests--;
runNext();
});
}
function throttled(fn) {
return new Promise((res) => {
queue.push({ resolve: res, fn });
runNext();
});
}
function getCachePath(url) {
const hash = createHash("md5").update(url).digest("hex");
@@ -13,22 +36,24 @@ function getCachePath(url) {
function readCache(url) {
const path = getCachePath(url);
if (!existsSync(path)) return null;
if (!existsSync(path)) return undefined; // undefined = not cached
try {
const data = JSON.parse(readFileSync(path, "utf-8"));
if (Date.now() - data.cachedAt < CACHE_DURATION_MS) {
return data.metadata;
const age = Date.now() - data.cachedAt;
const ttl = data.failed ? FAILURE_CACHE_MS : CACHE_DURATION_MS;
if (age < ttl) {
return data; // return full cache entry (includes .failed flag)
}
} catch {
// Corrupt cache file, ignore
}
return null;
return undefined;
}
function writeCache(url, metadata) {
function writeCache(url, metadata, failed = false) {
mkdirSync(CACHE_DIR, { recursive: true });
const path = getCachePath(url);
writeFileSync(path, JSON.stringify({ cachedAt: Date.now(), metadata }));
writeFileSync(path, JSON.stringify({ cachedAt: Date.now(), metadata, failed }));
}
function extractDomain(url) {
@@ -48,59 +73,38 @@ function escapeHtml(str) {
.replace(/"/g, "&quot;");
}
/**
* Register the {% unfurl "URL" %} shortcode on an Eleventy config.
*/
export default function registerUnfurlShortcode(eleventyConfig) {
eleventyConfig.addAsyncShortcode("unfurl", async function (url) {
if (!url) return "";
function renderFallbackLink(url) {
const domain = escapeHtml(extractDomain(url));
return `<a href="${escapeHtml(url)}" rel="noopener" target="_blank">${domain}</a>`;
}
// Check cache first
let metadata = readCache(url);
function renderCard(url, metadata) {
const og = metadata.open_graph || {};
const tc = metadata.twitter_card || {};
if (!metadata) {
try {
metadata = await unfurl(url, { timeout: 10000 });
writeCache(url, metadata);
} catch (err) {
console.warn(`[unfurl] Failed to fetch ${url}: ${err.message}`);
// Fallback: plain link
const domain = escapeHtml(extractDomain(url));
return `<a href="${escapeHtml(url)}" rel="noopener" target="_blank">${domain}</a>`;
}
}
const title = og.title || tc.title || metadata.title || extractDomain(url);
const description = og.description || tc.description || metadata.description || "";
const image = og.images?.[0]?.url || tc.images?.[0]?.url || null;
const favicon = metadata.favicon || null;
const domain = extractDomain(url);
const og = metadata.open_graph || {};
const tc = metadata.twitter_card || {};
const maxDesc = 160;
const desc = description.length > maxDesc
? description.slice(0, maxDesc).trim() + "\u2026"
: description;
const title = og.title || tc.title || metadata.title || extractDomain(url);
const description = og.description || tc.description || metadata.description || "";
const image =
og.images?.[0]?.url ||
tc.images?.[0]?.url ||
null;
const favicon = metadata.favicon || null;
const domain = extractDomain(url);
const imgHtml = image
? `<div class="unfurl-card-image shrink-0">
<img src="${escapeHtml(image)}" alt="" loading="lazy" decoding="async"
class="w-24 h-24 sm:w-32 sm:h-32 object-cover rounded-r-lg" />
</div>`
: "";
// Truncate description
const maxDesc = 160;
const desc = description.length > maxDesc
? description.slice(0, maxDesc).trim() + "\u2026"
: description;
const faviconHtml = favicon
? `<img src="${escapeHtml(favicon)}" alt="" class="inline-block w-4 h-4 mr-1 align-text-bottom" loading="lazy" />`
: "";
// Build card HTML
const imgHtml = image
? `<div class="unfurl-card-image shrink-0">
<img src="${escapeHtml(image)}" alt="" loading="lazy" decoding="async"
class="w-24 h-24 sm:w-32 sm:h-32 object-cover rounded-r-lg" />
</div>`
: "";
const faviconHtml = favicon
? `<img src="${escapeHtml(favicon)}" alt="" class="inline-block w-4 h-4 mr-1 align-text-bottom" loading="lazy" />`
: "";
return `<div class="unfurl-card not-prose my-4 rounded-lg border border-surface-200 dark:border-surface-700 bg-surface-50 dark:bg-surface-800 overflow-hidden hover:border-primary-300 dark:hover:border-primary-600 transition-colors">
return `<div class="unfurl-card not-prose my-4 rounded-lg border border-surface-200 dark:border-surface-700 bg-surface-50 dark:bg-surface-800 overflow-hidden hover:border-primary-300 dark:hover:border-primary-600 transition-colors">
<a href="${escapeHtml(url)}" rel="noopener" target="_blank" class="flex no-underline text-inherit hover:text-inherit">
<div class="flex-1 p-3 sm:p-4 min-w-0">
<p class="font-semibold text-sm sm:text-base text-surface-900 dark:text-surface-100 truncate m-0">${escapeHtml(title)}</p>
@@ -110,5 +114,40 @@ export default function registerUnfurlShortcode(eleventyConfig) {
${imgHtml}
</a>
</div>`;
}
/**
* Register the {% unfurl "URL" %} shortcode on an Eleventy config.
*/
export default function registerUnfurlShortcode(eleventyConfig) {
eleventyConfig.addAsyncShortcode("unfurl", async function (url) {
if (!url) return "";
// Check cache first (returns undefined if not cached)
const cached = readCache(url);
if (cached !== undefined) {
// Cached failure → render fallback link
if (cached.failed) return renderFallbackLink(url);
// Cached success → render card
return renderCard(url, cached.metadata);
}
// Not cached — fetch with concurrency limiting
const metadata = await throttled(async () => {
try {
return await unfurl(url, { timeout: 20000 });
} catch (err) {
console.warn(`[unfurl] Failed to fetch ${url}: ${err.message}`);
return null;
}
});
if (!metadata) {
writeCache(url, null, true); // cache failure for 1 day
return renderFallbackLink(url);
}
writeCache(url, metadata, false);
return renderCard(url, metadata);
});
}