mirror of
https://github.com/svemagie/blog-eleventy-indiekit.git
synced 2026-05-15 06:58:50 +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:
+69
-30
@@ -5,6 +5,29 @@ import { createHash } from "crypto";
|
|||||||
|
|
||||||
const CACHE_DIR = resolve(import.meta.dirname, "..", ".cache", "unfurl");
|
const CACHE_DIR = resolve(import.meta.dirname, "..", ".cache", "unfurl");
|
||||||
const CACHE_DURATION_MS = 7 * 24 * 60 * 60 * 1000; // 1 week
|
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) {
|
function getCachePath(url) {
|
||||||
const hash = createHash("md5").update(url).digest("hex");
|
const hash = createHash("md5").update(url).digest("hex");
|
||||||
@@ -13,22 +36,24 @@ function getCachePath(url) {
|
|||||||
|
|
||||||
function readCache(url) {
|
function readCache(url) {
|
||||||
const path = getCachePath(url);
|
const path = getCachePath(url);
|
||||||
if (!existsSync(path)) return null;
|
if (!existsSync(path)) return undefined; // undefined = not cached
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(readFileSync(path, "utf-8"));
|
const data = JSON.parse(readFileSync(path, "utf-8"));
|
||||||
if (Date.now() - data.cachedAt < CACHE_DURATION_MS) {
|
const age = Date.now() - data.cachedAt;
|
||||||
return data.metadata;
|
const ttl = data.failed ? FAILURE_CACHE_MS : CACHE_DURATION_MS;
|
||||||
|
if (age < ttl) {
|
||||||
|
return data; // return full cache entry (includes .failed flag)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Corrupt cache file, ignore
|
// Corrupt cache file, ignore
|
||||||
}
|
}
|
||||||
return null;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeCache(url, metadata) {
|
function writeCache(url, metadata, failed = false) {
|
||||||
mkdirSync(CACHE_DIR, { recursive: true });
|
mkdirSync(CACHE_DIR, { recursive: true });
|
||||||
const path = getCachePath(url);
|
const path = getCachePath(url);
|
||||||
writeFileSync(path, JSON.stringify({ cachedAt: Date.now(), metadata }));
|
writeFileSync(path, JSON.stringify({ cachedAt: Date.now(), metadata, failed }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractDomain(url) {
|
function extractDomain(url) {
|
||||||
@@ -48,47 +73,26 @@ function escapeHtml(str) {
|
|||||||
.replace(/"/g, """);
|
.replace(/"/g, """);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function renderFallbackLink(url) {
|
||||||
* 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
|
|
||||||
const domain = escapeHtml(extractDomain(url));
|
const domain = escapeHtml(extractDomain(url));
|
||||||
return `<a href="${escapeHtml(url)}" rel="noopener" target="_blank">${domain}</a>`;
|
return `<a href="${escapeHtml(url)}" rel="noopener" target="_blank">${domain}</a>`;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
function renderCard(url, metadata) {
|
||||||
const og = metadata.open_graph || {};
|
const og = metadata.open_graph || {};
|
||||||
const tc = metadata.twitter_card || {};
|
const tc = metadata.twitter_card || {};
|
||||||
|
|
||||||
const title = og.title || tc.title || metadata.title || extractDomain(url);
|
const title = og.title || tc.title || metadata.title || extractDomain(url);
|
||||||
const description = og.description || tc.description || metadata.description || "";
|
const description = og.description || tc.description || metadata.description || "";
|
||||||
const image =
|
const image = og.images?.[0]?.url || tc.images?.[0]?.url || null;
|
||||||
og.images?.[0]?.url ||
|
|
||||||
tc.images?.[0]?.url ||
|
|
||||||
null;
|
|
||||||
const favicon = metadata.favicon || null;
|
const favicon = metadata.favicon || null;
|
||||||
const domain = extractDomain(url);
|
const domain = extractDomain(url);
|
||||||
|
|
||||||
// Truncate description
|
|
||||||
const maxDesc = 160;
|
const maxDesc = 160;
|
||||||
const desc = description.length > maxDesc
|
const desc = description.length > maxDesc
|
||||||
? description.slice(0, maxDesc).trim() + "\u2026"
|
? description.slice(0, maxDesc).trim() + "\u2026"
|
||||||
: description;
|
: description;
|
||||||
|
|
||||||
// Build card HTML
|
|
||||||
const imgHtml = image
|
const imgHtml = image
|
||||||
? `<div class="unfurl-card-image shrink-0">
|
? `<div class="unfurl-card-image shrink-0">
|
||||||
<img src="${escapeHtml(image)}" alt="" loading="lazy" decoding="async"
|
<img src="${escapeHtml(image)}" alt="" loading="lazy" decoding="async"
|
||||||
@@ -110,5 +114,40 @@ export default function registerUnfurlShortcode(eleventyConfig) {
|
|||||||
${imgHtml}
|
${imgHtml}
|
||||||
</a>
|
</a>
|
||||||
</div>`;
|
</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