mirror of
https://github.com/svemagie/blog-eleventy-indiekit.git
synced 2026-04-02 16:44:56 +02:00
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:
@@ -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,47 +73,26 @@ function escapeHtml(str) {
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
let metadata = readCache(url);
|
||||
|
||||
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
|
||||
function renderFallbackLink(url) {
|
||||
const domain = escapeHtml(extractDomain(url));
|
||||
return `<a href="${escapeHtml(url)}" rel="noopener" target="_blank">${domain}</a>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderCard(url, metadata) {
|
||||
const og = metadata.open_graph || {};
|
||||
const tc = metadata.twitter_card || {};
|
||||
|
||||
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 image = og.images?.[0]?.url || tc.images?.[0]?.url || null;
|
||||
const favicon = metadata.favicon || null;
|
||||
const domain = extractDomain(url);
|
||||
|
||||
// Truncate description
|
||||
const maxDesc = 160;
|
||||
const desc = description.length > maxDesc
|
||||
? description.slice(0, maxDesc).trim() + "\u2026"
|
||||
: description;
|
||||
|
||||
// Build card HTML
|
||||
const imgHtml = image
|
||||
? `<div class="unfurl-card-image shrink-0">
|
||||
<img src="${escapeHtml(image)}" alt="" loading="lazy" decoding="async"
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user