perf: add timeout and watch-mode cache extension to all data files

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
This commit is contained in:
Ricardo
2026-03-10 17:11:24 +01:00
parent f7d452fc30
commit 0fe99ee5b1
14 changed files with 82 additions and 28 deletions

View File

@@ -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",
});

View File

@@ -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: {

View File

@@ -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" }
);

View File

@@ -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",
});

View File

@@ -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 },

View File

@@ -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: {

View File

@@ -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",
});

View File

@@ -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",
});

View File

@@ -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: {

View File

@@ -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",
});

View File

@@ -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",
});

View File

@@ -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",
});

View File

@@ -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",
});

54
lib/data-fetch.js Normal file
View File

@@ -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<any>} 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);
}
}