From 0fe99ee5b116585355d22a89a5331a761170a55a Mon Sep 17 00:00:00 2001 From: Ricardo Date: Tue, 10 Mar 2026 17:11:24 +0100 Subject: [PATCH] perf: add timeout and watch-mode cache extension to all data files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce shared cachedFetch helper (lib/data-fetch.js) wrapping EleventyFetch with two protections: - 10-second hard timeout via AbortController on every network request, preventing slow or unresponsive APIs from hanging the build - 4-hour cache TTL in watch/serve mode (vs 5-15 min originals), so incremental rebuilds serve from disk cache instead of re-fetching APIs every time a markdown file changes All 13 network _data files updated to use cachedFetch. Production builds keep original short TTLs for fresh data. Targets the "Data File" benchmark (12,169ms / 32% of incremental rebuild) — the largest remaining bottleneck after filter memoization. Confab-Link: http://localhost:8080/sessions/0b241cd6-aff2-4fec-853c-2b5a61e61946 --- _data/blogrollStatus.js | 4 +-- _data/blueskyFeed.js | 4 +-- _data/conversationMentions.js | 4 +-- _data/funkwhaleActivity.js | 4 +-- _data/githubActivity.js | 6 ++-- _data/githubRepos.js | 4 +-- _data/githubStarred.js | 4 +-- _data/lastfmActivity.js | 4 +-- _data/mastodonFeed.js | 6 ++-- _data/newsActivity.js | 4 +-- _data/podrollStatus.js | 4 +-- _data/recentComments.js | 4 +-- _data/youtubeChannel.js | 4 +-- lib/data-fetch.js | 54 +++++++++++++++++++++++++++++++++++ 14 files changed, 82 insertions(+), 28 deletions(-) create mode 100644 lib/data-fetch.js diff --git a/_data/blogrollStatus.js b/_data/blogrollStatus.js index aee1601..d2999c1 100644 --- a/_data/blogrollStatus.js +++ b/_data/blogrollStatus.js @@ -4,7 +4,7 @@ * Used for conditional navigation — the blogroll page itself loads data client-side. */ -import EleventyFetch from "@11ty/eleventy-fetch"; +import { cachedFetch } from "../lib/data-fetch.js"; const INDIEKIT_URL = process.env.SITE_URL || "https://example.com"; @@ -12,7 +12,7 @@ export default async function () { try { const url = `${INDIEKIT_URL}/blogrollapi/api/status`; console.log(`[blogrollStatus] Checking API: ${url}`); - const data = await EleventyFetch(url, { + const data = await cachedFetch(url, { duration: "15m", type: "json", }); diff --git a/_data/blueskyFeed.js b/_data/blueskyFeed.js index b0083bc..2f3eded 100644 --- a/_data/blueskyFeed.js +++ b/_data/blueskyFeed.js @@ -3,7 +3,7 @@ * Fetches recent posts from Bluesky using the AT Protocol API */ -import EleventyFetch from "@11ty/eleventy-fetch"; +import { cachedFetch } from "../lib/data-fetch.js"; import { BskyAgent } from "@atproto/api"; export default async function () { @@ -16,7 +16,7 @@ export default async function () { // Get the author's feed using public API (no auth needed for public posts) const feedUrl = `https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor=${handle}&limit=10`; - const response = await EleventyFetch(feedUrl, { + const response = await cachedFetch(feedUrl, { duration: "15m", // Cache for 15 minutes type: "json", fetchOptions: { diff --git a/_data/conversationMentions.js b/_data/conversationMentions.js index 1652f37..4af7bc9 100644 --- a/_data/conversationMentions.js +++ b/_data/conversationMentions.js @@ -1,8 +1,8 @@ -import EleventyFetch from "@11ty/eleventy-fetch"; +import { cachedFetch } from "../lib/data-fetch.js"; export default async function () { try { - const data = await EleventyFetch( + const data = await cachedFetch( "http://127.0.0.1:8080/conversations/api/mentions?per-page=10000", { duration: "15m", type: "json" } ); diff --git a/_data/funkwhaleActivity.js b/_data/funkwhaleActivity.js index 316aeb4..b32a082 100644 --- a/_data/funkwhaleActivity.js +++ b/_data/funkwhaleActivity.js @@ -3,7 +3,7 @@ * Fetches from Indiekit's endpoint-funkwhale public API */ -import EleventyFetch from "@11ty/eleventy-fetch"; +import { cachedFetch } from "../lib/data-fetch.js"; const INDIEKIT_URL = process.env.SITE_URL || "https://example.com"; const FUNKWHALE_INSTANCE = process.env.FUNKWHALE_INSTANCE || ""; @@ -15,7 +15,7 @@ async function fetchFromIndiekit(endpoint) { try { const url = `${INDIEKIT_URL}/funkwhaleapi/api/${endpoint}`; console.log(`[funkwhaleActivity] Fetching from Indiekit: ${url}`); - const data = await EleventyFetch(url, { + const data = await cachedFetch(url, { duration: "15m", type: "json", }); diff --git a/_data/githubActivity.js b/_data/githubActivity.js index d8a19a7..2d5dfef 100644 --- a/_data/githubActivity.js +++ b/_data/githubActivity.js @@ -4,7 +4,7 @@ * Falls back to direct GitHub API if Indiekit is unavailable */ -import EleventyFetch from "@11ty/eleventy-fetch"; +import { cachedFetch } from "../lib/data-fetch.js"; const GITHUB_USERNAME = process.env.GITHUB_USERNAME || ""; const INDIEKIT_URL = process.env.SITE_URL || "https://example.com"; @@ -19,7 +19,7 @@ async function fetchFromIndiekit(endpoint) { try { const url = `${INDIEKIT_URL}/githubapi/api/${endpoint}`; console.log(`[githubActivity] Fetching from Indiekit: ${url}`); - const data = await EleventyFetch(url, { + const data = await cachedFetch(url, { duration: "15m", type: "json", }); @@ -47,7 +47,7 @@ async function fetchFromGitHub(endpoint) { headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`; } - return await EleventyFetch(url, { + return await cachedFetch(url, { duration: "15m", type: "json", fetchOptions: { headers }, diff --git a/_data/githubRepos.js b/_data/githubRepos.js index 1b8fe5f..9b1bc34 100644 --- a/_data/githubRepos.js +++ b/_data/githubRepos.js @@ -3,7 +3,7 @@ * Fetches public repositories from GitHub API */ -import EleventyFetch from "@11ty/eleventy-fetch"; +import { cachedFetch } from "../lib/data-fetch.js"; export default async function () { const username = process.env.GITHUB_USERNAME || ""; @@ -12,7 +12,7 @@ export default async function () { // Fetch public repos, sorted by updated date const url = `https://api.github.com/users/${username}/repos?sort=updated&per_page=10&type=owner`; - const repos = await EleventyFetch(url, { + const repos = await cachedFetch(url, { duration: "1h", // Cache for 1 hour type: "json", fetchOptions: { diff --git a/_data/githubStarred.js b/_data/githubStarred.js index 659d181..d7ae308 100644 --- a/_data/githubStarred.js +++ b/_data/githubStarred.js @@ -6,14 +6,14 @@ * The starred page fetches all data client-side via Alpine.js. */ -import EleventyFetch from "@11ty/eleventy-fetch"; +import { cachedFetch } from "../lib/data-fetch.js"; const INDIEKIT_URL = process.env.SITE_URL || "https://example.com"; export default async function () { try { const url = `${INDIEKIT_URL}/githubapi/api/starred/all`; - const response = await EleventyFetch(url, { + const response = await cachedFetch(url, { duration: "15m", type: "json", }); diff --git a/_data/lastfmActivity.js b/_data/lastfmActivity.js index d7dd837..3883f96 100644 --- a/_data/lastfmActivity.js +++ b/_data/lastfmActivity.js @@ -3,7 +3,7 @@ * Fetches from Indiekit's endpoint-lastfm public API */ -import EleventyFetch from "@11ty/eleventy-fetch"; +import { cachedFetch } from "../lib/data-fetch.js"; const INDIEKIT_URL = process.env.SITE_URL || "https://example.com"; const LASTFM_USERNAME = process.env.LASTFM_USERNAME || ""; @@ -15,7 +15,7 @@ async function fetchFromIndiekit(endpoint) { try { const url = `${INDIEKIT_URL}/lastfmapi/api/${endpoint}`; console.log(`[lastfmActivity] Fetching from Indiekit: ${url}`); - const data = await EleventyFetch(url, { + const data = await cachedFetch(url, { duration: "15m", type: "json", }); diff --git a/_data/mastodonFeed.js b/_data/mastodonFeed.js index 0c79abf..1ead479 100644 --- a/_data/mastodonFeed.js +++ b/_data/mastodonFeed.js @@ -3,7 +3,7 @@ * Fetches recent posts from Mastodon using the public API */ -import EleventyFetch from "@11ty/eleventy-fetch"; +import { cachedFetch } from "../lib/data-fetch.js"; export default async function () { const instance = process.env.MASTODON_INSTANCE?.replace("https://", "") || ""; @@ -13,7 +13,7 @@ export default async function () { // First, look up the account ID const lookupUrl = `https://${instance}/api/v1/accounts/lookup?acct=${username}`; - const account = await EleventyFetch(lookupUrl, { + const account = await cachedFetch(lookupUrl, { duration: "1h", // Cache account lookup for 1 hour type: "json", fetchOptions: { @@ -31,7 +31,7 @@ export default async function () { // Fetch recent statuses (excluding replies and boosts for cleaner feed) const statusesUrl = `https://${instance}/api/v1/accounts/${account.id}/statuses?limit=10&exclude_replies=true&exclude_reblogs=true`; - const statuses = await EleventyFetch(statusesUrl, { + const statuses = await cachedFetch(statusesUrl, { duration: "15m", // Cache for 15 minutes type: "json", fetchOptions: { diff --git a/_data/newsActivity.js b/_data/newsActivity.js index 47499b8..3c63883 100644 --- a/_data/newsActivity.js +++ b/_data/newsActivity.js @@ -3,7 +3,7 @@ * Fetches from Indiekit's endpoint-rss public API */ -import EleventyFetch from "@11ty/eleventy-fetch"; +import { cachedFetch } from "../lib/data-fetch.js"; const INDIEKIT_URL = process.env.SITE_URL || "https://example.com"; @@ -14,7 +14,7 @@ async function fetchFromIndiekit(endpoint) { try { const url = `${INDIEKIT_URL}/rssapi/api/${endpoint}`; console.log(`[newsActivity] Fetching from Indiekit: ${url}`); - const data = await EleventyFetch(url, { + const data = await cachedFetch(url, { duration: "15m", type: "json", }); diff --git a/_data/podrollStatus.js b/_data/podrollStatus.js index cf117e5..9ac88cb 100644 --- a/_data/podrollStatus.js +++ b/_data/podrollStatus.js @@ -4,7 +4,7 @@ * Used for conditional navigation — the podroll page itself loads data client-side. */ -import EleventyFetch from "@11ty/eleventy-fetch"; +import { cachedFetch } from "../lib/data-fetch.js"; const INDIEKIT_URL = process.env.SITE_URL || "https://example.com"; @@ -12,7 +12,7 @@ export default async function () { try { const url = `${INDIEKIT_URL}/podrollapi/api/status`; console.log(`[podrollStatus] Checking API: ${url}`); - const data = await EleventyFetch(url, { + const data = await cachedFetch(url, { duration: "15m", type: "json", }); diff --git a/_data/recentComments.js b/_data/recentComments.js index bdd1ede..0f9edda 100644 --- a/_data/recentComments.js +++ b/_data/recentComments.js @@ -3,7 +3,7 @@ * Fetches the 5 most recent comments at build time for the sidebar widget. */ -import EleventyFetch from "@11ty/eleventy-fetch"; +import { cachedFetch } from "../lib/data-fetch.js"; const INDIEKIT_URL = process.env.SITE_URL || "https://example.com"; @@ -11,7 +11,7 @@ export default async function () { try { const url = `${INDIEKIT_URL}/comments/api/comments?limit=5`; console.log(`[recentComments] Fetching: ${url}`); - const data = await EleventyFetch(url, { + const data = await cachedFetch(url, { duration: "15m", type: "json", }); diff --git a/_data/youtubeChannel.js b/_data/youtubeChannel.js index 7fbf461..81be351 100644 --- a/_data/youtubeChannel.js +++ b/_data/youtubeChannel.js @@ -4,7 +4,7 @@ * Supports single or multiple channels */ -import EleventyFetch from "@11ty/eleventy-fetch"; +import { cachedFetch } from "../lib/data-fetch.js"; const INDIEKIT_URL = process.env.SITE_URL || "https://example.com"; @@ -15,7 +15,7 @@ async function fetchFromIndiekit(endpoint) { try { const url = `${INDIEKIT_URL}/youtubeapi/api/${endpoint}`; console.log(`[youtubeChannel] Fetching from Indiekit: ${url}`); - const data = await EleventyFetch(url, { + const data = await cachedFetch(url, { duration: "5m", type: "json", }); diff --git a/lib/data-fetch.js b/lib/data-fetch.js new file mode 100644 index 0000000..22bd6cb --- /dev/null +++ b/lib/data-fetch.js @@ -0,0 +1,54 @@ +/** + * Shared data-fetching helper for _data files. + * + * Wraps @11ty/eleventy-fetch with two protections: + * 1. Hard timeout — 10-second AbortController ceiling on every request + * 2. Watch-mode cache extension — uses "4h" TTL during watch/serve, + * keeping the original (shorter) TTL only for production builds + * + * Usage: + * import { cachedFetch } from "../lib/data-fetch.js"; + * const data = await cachedFetch(url, { duration: "15m", type: "json" }); + */ + +import EleventyFetch from "@11ty/eleventy-fetch"; + +const FETCH_TIMEOUT_MS = 10_000; // 10 seconds + +// In watch/serve mode, extend cache to avoid re-fetching on every rebuild. +// Production builds use the caller's original TTL for fresh data. +const isWatchMode = process.env.ELEVENTY_RUN_MODE !== "build"; +const WATCH_MODE_DURATION = "4h"; + +/** + * Fetch with timeout and watch-mode cache extension. + * + * @param {string} url - URL to fetch + * @param {object} options - EleventyFetch options (duration, type, fetchOptions, etc.) + * @returns {Promise} Parsed response + */ +export async function cachedFetch(url, options = {}) { + // Extend cache in watch mode + const duration = isWatchMode ? WATCH_MODE_DURATION : (options.duration || "15m"); + + // Create abort controller for hard timeout + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + try { + const fetchOptions = { + ...options.fetchOptions, + signal: controller.signal, + }; + + const result = await EleventyFetch(url, { + ...options, + duration, + fetchOptions, + }); + + return result; + } finally { + clearTimeout(timeoutId); + } +}