From 4b0a9313a577c37fd76d17c8b32d133a0689f669 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 17 Nov 2025 13:35:48 +0100 Subject: [PATCH 1/3] VIbe-code some optimizations --- web/biome.jsonc | 2 +- web/package.json | 3 +- web/scripts/benchmark-og-images.tsx | 419 ++++++++++++++++++ .../app/community/[icon]/opengraph-image.tsx | 49 +- web/src/app/icons/[icon]/opengraph-image.tsx | 64 +-- web/src/lib/api.ts | 38 +- web/src/lib/errors.ts | 3 - web/src/lib/icon-cache.ts | 76 ++++ 8 files changed, 598 insertions(+), 56 deletions(-) create mode 100644 web/scripts/benchmark-og-images.tsx create mode 100644 web/src/lib/icon-cache.ts diff --git a/web/biome.jsonc b/web/biome.jsonc index 498148cf..aa97117f 100644 --- a/web/biome.jsonc +++ b/web/biome.jsonc @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.2.4/schema.json", + "$schema": "https://biomejs.dev/schemas/2.3.5/schema.json", "vcs": { "enabled": true, "clientKind": "git", diff --git a/web/package.json b/web/package.json index d719d360..3789b19a 100644 --- a/web/package.json +++ b/web/package.json @@ -13,7 +13,8 @@ "ci": "biome check --write", "backend:start": "cd backend && ./pocketbase serve", "backend:download": "cd backend && curl -L -o pocketbase.zip https://github.com/pocketbase/pocketbase/releases/download/v0.30.0/pocketbase_0.30.0_darwin_arm64.zip && unzip pocketbase.zip && rm pocketbase.zip && rm CHANGELOG.md && rm LICENSE.md", - "seed": "bun run seed-db.ts" + "seed": "bun run seed-db.ts", + "benchmark:og": "bun run scripts/benchmark-og-images.tsx" }, "dependencies": { "@hookform/resolvers": "^5.2.1", diff --git a/web/scripts/benchmark-og-images.tsx b/web/scripts/benchmark-og-images.tsx new file mode 100644 index 00000000..107cab02 --- /dev/null +++ b/web/scripts/benchmark-og-images.tsx @@ -0,0 +1,419 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { ImageResponse } from "next/og"; +import React from "react"; +import { METADATA_URL } from "../src/constants"; +import type { IconFile } from "../src/types/icons"; + +// Standalone cached functions for benchmarking (no Next.js dependencies) +let iconsDataCache: IconFile | null = null; +const iconFileCache = new Map(); +let preloadDone = false; + +async function getAllIconsStandalone(): Promise { + if (iconsDataCache) { + return iconsDataCache; + } + const response = await fetch(METADATA_URL); + if (!response.ok) { + throw new Error(`Failed to fetch icons: ${response.statusText}`); + } + iconsDataCache = (await response.json()) as IconFile; + return iconsDataCache; +} + +async function preloadAllIconsStandalone(): Promise { + if (preloadDone) { + return; + } + const startTime = Date.now(); + const iconsData = await getAllIconsStandalone(); + const iconNames = Object.keys(iconsData); + const pngDir = join(process.cwd(), `../png`); + + console.log(`[Preload] Loading ${iconNames.length} icons into memory...`); + + const loadPromises = iconNames.map(async (iconName) => { + if (iconFileCache.has(iconName)) { + return; + } + try { + const iconPath = join(pngDir, `${iconName}.png`); + const buffer = await readFile(iconPath); + iconFileCache.set(iconName, buffer); + } catch (_error) { + iconFileCache.set(iconName, null); + } + }); + + await Promise.all(loadPromises); + const duration = Date.now() - startTime; + const loadedCount = Array.from(iconFileCache.values()).filter( + (v) => v !== null, + ).length; + console.log( + `[Preload] Loaded ${loadedCount}/${iconNames.length} icons in ${duration}ms (${(loadedCount / duration).toFixed(2)} icons/ms)\n`, + ); + preloadDone = true; +} + +async function readIconFileStandalone( + iconName: string, +): Promise { + if (iconFileCache.has(iconName)) { + return iconFileCache.get(iconName)!; + } + try { + const iconPath = join(process.cwd(), `../png/${iconName}.png`); + const buffer = await readFile(iconPath); + iconFileCache.set(iconName, buffer); + return buffer; + } catch (_error) { + iconFileCache.set(iconName, null); + return null; + } +} + +const size = { + width: 1200, + height: 630, +}; + +async function generateOGImage( + icon: string, + iconsData: Record, + totalIcons: number, + index: number, + profileTimings: Map, +) { + const stepTimings: Record = {}; + let stepStart: number; + + stepStart = Date.now(); + const formattedIconName = icon + .split("-") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); + stepTimings.formatName = Date.now() - stepStart; + + stepStart = Date.now(); + const iconData = await readIconFileStandalone(icon); + stepTimings.readFile = Date.now() - stepStart; + + stepStart = Date.now(); + const iconUrl = iconData + ? `data:image/png;base64,${iconData.toString("base64")}` + : null; + stepTimings.base64 = Date.now() - stepStart; + + stepStart = Date.now(); + const imageResponse = await new ImageResponse( +
+
+
+
+
+
+ {iconUrl ? ( + {formattedIconName} + ) : ( +
+ {formattedIconName} +
+ )} +
+
+
+ Download {formattedIconName} icon for free +
+
+ Amongst {totalIcons} other high-quality dashboard icons +
+
+ {["SVG", "PNG", "WEBP"].map((format) => ( +
+ {format} +
+ ))} +
+
+
+
+
+
+ dashboardicons.com +
+
+
, + { + ...size, + }, + ); + stepTimings.imageResponse = Date.now() - stepStart; + + for (const [step, timing] of Object.entries(stepTimings)) { + if (!profileTimings.has(step)) { + profileTimings.set(step, []); + } + profileTimings.get(step)!.push(timing); + } + + return imageResponse; +} + +async function benchmark() { + console.log("Starting OG image generation benchmark...\n"); + + const startTime = Date.now(); + + console.log("Fetching icons data..."); + const iconsData = await getAllIconsStandalone(); + const iconNames = Object.keys(iconsData); + const totalIcons = iconNames.length; + const testIcons = iconNames.slice(0, 100); + + await preloadAllIconsStandalone(); + + console.log(`Testing with ${testIcons.length} icons\n`); + + const times: number[] = []; + const profileTimings = new Map(); + + for (let i = 0; i < testIcons.length; i++) { + const icon = testIcons[i]; + const iconStartTime = Date.now(); + + try { + await generateOGImage(icon, iconsData, totalIcons, i, profileTimings); + const iconEndTime = Date.now(); + const duration = iconEndTime - iconStartTime; + times.push(duration); + + if ((i + 1) % 10 === 0) { + const avgTime = times.slice(-10).reduce((a, b) => a + b, 0) / 10; + console.log( + `Generated ${i + 1}/${testIcons.length} images (avg: ${avgTime.toFixed(2)}ms per image)`, + ); + } + } catch (error) { + console.error(`Failed to generate image for ${icon}:`, error); + } + } + + const endTime = Date.now(); + const totalDuration = endTime - startTime; + const avgTime = times.reduce((a, b) => a + b, 0) / times.length; + const minTime = Math.min(...times); + const maxTime = Math.max(...times); + + console.log("\n" + "=".repeat(50)); + console.log("Benchmark Results"); + console.log("=".repeat(50)); + console.log(`Total images generated: ${testIcons.length}`); + console.log(`Total time: ${(totalDuration / 1000).toFixed(2)}s`); + console.log(`Average time per image: ${avgTime.toFixed(2)}ms`); + console.log(`Min time: ${minTime.toFixed(2)}ms`); + console.log(`Max time: ${maxTime.toFixed(2)}ms`); + console.log( + `Images per second: ${((testIcons.length / totalDuration) * 1000).toFixed(2)}`, + ); + console.log("\n" + "-".repeat(50)); + console.log("Performance Breakdown (per image):"); + console.log("-".repeat(50)); + for (const [step, timings] of profileTimings.entries()) { + const avg = timings.reduce((a, b) => a + b, 0) / timings.length; + const min = Math.min(...timings); + const max = Math.max(...timings); + const total = timings.reduce((a, b) => a + b, 0); + const percentage = ( + (total / times.reduce((a, b) => a + b, 0)) * + 100 + ).toFixed(1); + console.log( + ` ${step.padEnd(15)}: avg ${avg.toFixed(2)}ms | min ${min.toFixed(2)}ms | max ${max.toFixed(2)}ms | ${percentage}%`, + ); + } + console.log("=".repeat(50)); +} + +benchmark().catch(console.error); diff --git a/web/src/app/community/[icon]/opengraph-image.tsx b/web/src/app/community/[icon]/opengraph-image.tsx index 61da7c4c..5b8913e5 100644 --- a/web/src/app/community/[icon]/opengraph-image.tsx +++ b/web/src/app/community/[icon]/opengraph-image.tsx @@ -135,9 +135,7 @@ export default async function Image({ params }: { params: Promise<{ icon: string } } - const iconUrl = iconDataBuffer - ? `data:image/png;base64,${iconDataBuffer.toString("base64")}` - : `https://placehold.co/600x400?text=${formattedIconName}` + const iconUrl = iconDataBuffer ? `data:image/png;base64,${iconDataBuffer.toString("base64")}` : null return new ImageResponse(
- {formattedIconName} + {iconUrl ? ( + {formattedIconName} + ) : ( +
+ {formattedIconName} +
+ )}
({ @@ -49,27 +47,18 @@ export default async function Image({ params }: { params: Promise<{ icon: string ) } + await preloadAllIcons() + const iconsData = await getAllIcons() const totalIcons = Object.keys(iconsData).length const index = Object.keys(iconsData).indexOf(icon) - // Format the icon name for display const formattedIconName = icon .split("-") .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(" ") - // Read the icon file from local filesystem - let iconData: Buffer | null = null - try { - const iconPath = join(process.cwd(), `../png/${icon}.png`) - console.log(`Generating opengraph image for ${icon} (${index + 1} / ${totalIcons}) from path ${iconPath}`) - iconData = await readFile(iconPath) - } catch (_error) { - console.error(`Icon ${icon} was not found locally`) - } - - // Convert the image data to a data URL or use placeholder + const iconData = await readIconFile(icon) const iconUrl = iconData ? `data:image/png;base64,${iconData.toString("base64")}` : null return new ImageResponse( @@ -154,18 +143,39 @@ export default async function Image({ params }: { params: Promise<{ icon: string zIndex: 0, }} /> - {formattedIconName} + {iconUrl ? ( + {formattedIconName} + ) : ( +
+ {formattedIconName} +
+ )}
{/* Text content */} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 275f5294..c9fa0b5b 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1,15 +1,17 @@ import { unstable_cache } from "next/cache" +import { cache } from "react" import { METADATA_URL } from "@/constants" import { ApiError } from "@/lib/errors" import type { AuthorData, IconFile, IconWithName } from "@/types/icons" /** - * Fetches all icon data from the metadata.json file - * Uses fetch with revalidate for caching + * Raw fetch function for icon data (without caching) */ -export async function getAllIcons(): Promise { +async function fetchAllIconsRaw(): Promise { try { - const response = await fetch(METADATA_URL) + const response = await fetch(METADATA_URL, { + next: { revalidate: 3600 }, + }) if (!response.ok) { throw new ApiError(`Failed to fetch icons: ${response.statusText}`, response.status) @@ -25,6 +27,24 @@ export async function getAllIcons(): Promise { } } +/** + * Cached version using unstable_cache for build-time caching + * Revalidates every hour (3600 seconds) + */ +const getAllIconsCached = unstable_cache(async () => fetchAllIconsRaw(), ["all-icons"], { + revalidate: 3600, + tags: ["icons"], +}) + +/** + * Fetches all icon data from the metadata.json file + * Uses React cache() for request-level memoization and unstable_cache for build-level caching + * This prevents duplicate fetches within the same request and across builds + */ +export const getAllIcons = cache(async (): Promise => { + return getAllIconsCached() +}) + /** * Gets a list of all icon names. */ @@ -122,7 +142,7 @@ async function fetchAuthorData(authorId: number) { } } -const authorDataCache: Record = {}; +const authorDataCache: Record = {} /** * Cached version of fetchAuthorData @@ -135,12 +155,12 @@ const authorDataCache: Record = {}; */ export async function getAuthorData(authorId: number): Promise { if (authorDataCache[authorId]) { - return authorDataCache[authorId]; + return authorDataCache[authorId] } - const data = await fetchAuthorData(authorId); - authorDataCache[authorId] = data; - return data; + const data = await fetchAuthorData(authorId) + authorDataCache[authorId] = data + return data } /** diff --git a/web/src/lib/errors.ts b/web/src/lib/errors.ts index 7c7b203a..e1f6eceb 100644 --- a/web/src/lib/errors.ts +++ b/web/src/lib/errors.ts @@ -10,6 +10,3 @@ export class ApiError extends Error { this.status = status } } - - - diff --git a/web/src/lib/icon-cache.ts b/web/src/lib/icon-cache.ts new file mode 100644 index 00000000..09bb582a --- /dev/null +++ b/web/src/lib/icon-cache.ts @@ -0,0 +1,76 @@ +import { readFile } from "node:fs/promises" +import { join } from "node:path" +import { cache } from "react" +import { getAllIcons } from "./api" + +/** + * In-memory cache for icon files during build/request + * This persists across multiple calls within the same build process + */ +const iconFileCache = new Map() +let preloadPromise: Promise | null = null +let isPreloaded = false + +/** + * Preloads all icon files into memory + * This should be called once at the start of the build process + */ +export async function preloadAllIcons(): Promise { + if (isPreloaded || preloadPromise) { + return preloadPromise || Promise.resolve() + } + + preloadPromise = (async () => { + const startTime = Date.now() + const iconsData = await getAllIcons() + const iconNames = Object.keys(iconsData) + const pngDir = join(process.cwd(), `../png`) + + console.log(`[Icon Cache] Preloading ${iconNames.length} icons into memory...`) + + const loadPromises = iconNames.map(async (iconName) => { + if (iconFileCache.has(iconName)) { + return + } + try { + const iconPath = join(pngDir, `${iconName}.png`) + const buffer = await readFile(iconPath) + iconFileCache.set(iconName, buffer) + } catch (_error) { + iconFileCache.set(iconName, null) + } + }) + + await Promise.all(loadPromises) + const duration = Date.now() - startTime + const loadedCount = Array.from(iconFileCache.values()).filter((v) => v !== null).length + console.log( + `[Icon Cache] Preloaded ${loadedCount}/${iconNames.length} icons in ${duration}ms (${(loadedCount / duration).toFixed(2)} icons/ms)`, + ) + isPreloaded = true + })() + + return preloadPromise +} + +/** + * Reads an icon PNG file from the filesystem + * Uses React cache() for request-level memoization + * Uses in-memory Map for build-level caching + * If preloaded, returns immediately from cache + */ +export const readIconFile = cache(async (iconName: string): Promise => { + if (iconFileCache.has(iconName)) { + return iconFileCache.get(iconName)! + } + + try { + const iconPath = join(process.cwd(), `../png/${iconName}.png`) + const buffer = await readFile(iconPath) + iconFileCache.set(iconName, buffer) + return buffer + } catch (_error) { + iconFileCache.set(iconName, null) + return null + } +}) From 2e20511872c462db00ae1179a5a13252f65727e0 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 17 Nov 2025 13:36:00 +0100 Subject: [PATCH 2/3] VIbe-code some optimizations --- web/src/lib/api.ts | 131 ++++++++++++++++++++++++++------------------- 1 file changed, 75 insertions(+), 56 deletions(-) diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index c9fa0b5b..74016469 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1,8 +1,8 @@ -import { unstable_cache } from "next/cache" -import { cache } from "react" -import { METADATA_URL } from "@/constants" -import { ApiError } from "@/lib/errors" -import type { AuthorData, IconFile, IconWithName } from "@/types/icons" +import { unstable_cache } from "next/cache"; +import { cache } from "react"; +import { METADATA_URL } from "@/constants"; +import { ApiError } from "@/lib/errors"; +import type { AuthorData, IconFile, IconWithName } from "@/types/icons"; /** * Raw fetch function for icon data (without caching) @@ -11,19 +11,22 @@ async function fetchAllIconsRaw(): Promise { try { const response = await fetch(METADATA_URL, { next: { revalidate: 3600 }, - }) + }); if (!response.ok) { - throw new ApiError(`Failed to fetch icons: ${response.statusText}`, response.status) + throw new ApiError( + `Failed to fetch icons: ${response.statusText}`, + response.status, + ); } - return (await response.json()) as IconFile + return (await response.json()) as IconFile; } catch (error) { if (error instanceof ApiError) { - throw error + throw error; } - console.error("Error fetching icons:", error) - throw new ApiError("Failed to fetch icons data. Please try again later.") + console.error("Error fetching icons:", error); + throw new ApiError("Failed to fetch icons data. Please try again later."); } } @@ -31,10 +34,14 @@ async function fetchAllIconsRaw(): Promise { * Cached version using unstable_cache for build-time caching * Revalidates every hour (3600 seconds) */ -const getAllIconsCached = unstable_cache(async () => fetchAllIconsRaw(), ["all-icons"], { - revalidate: 3600, - tags: ["icons"], -}) +const getAllIconsCached = unstable_cache( + async () => fetchAllIconsRaw(), + ["all-icons"], + { + revalidate: 3600, + tags: ["icons"], + }, +); /** * Fetches all icon data from the metadata.json file @@ -42,63 +49,65 @@ const getAllIconsCached = unstable_cache(async () => fetchAllIconsRaw(), ["all-i * This prevents duplicate fetches within the same request and across builds */ export const getAllIcons = cache(async (): Promise => { - return getAllIconsCached() -}) + return getAllIconsCached(); +}); /** * Gets a list of all icon names. */ export const getIconNames = async (): Promise => { try { - const iconsData = await getAllIcons() - return Object.keys(iconsData) + const iconsData = await getAllIcons(); + return Object.keys(iconsData); } catch (error) { - console.error("Error getting icon names:", error) - throw error + console.error("Error getting icon names:", error); + throw error; } -} +}; /** * Converts icon data to an array format for easier rendering */ export async function getIconsArray(): Promise { try { - const iconsData = await getAllIcons() + const iconsData = await getAllIcons(); return Object.entries(iconsData) .map(([name, data]) => ({ name, data, })) - .sort((a, b) => a.name.localeCompare(b.name)) + .sort((a, b) => a.name.localeCompare(b.name)); } catch (error) { - console.error("Error getting icons array:", error) - throw error + console.error("Error getting icons array:", error); + throw error; } } /** * Fetches data for a specific icon */ -export async function getIconData(iconName: string): Promise { +export async function getIconData( + iconName: string, +): Promise { try { - const iconsData = await getAllIcons() - const iconData = iconsData[iconName] + const iconsData = await getAllIcons(); + const iconData = iconsData[iconName]; if (!iconData) { - throw new ApiError(`Icon '${iconName}' not found`, 404) + throw new ApiError(`Icon '${iconName}' not found`, 404); } return { name: iconName, data: iconData, - } + }; } catch (error) { if (error instanceof ApiError && error.status === 404) { - return null + return null; } - console.error("Error getting icon data:", error) - throw error + console.error("Error getting icon data:", error); + throw error; } } @@ -111,26 +120,31 @@ async function fetchAuthorData(authorId: number) { headers: { Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, }, - }) + }); if (!response.ok) { // If unauthorized or other error, return a default user object if (response.status === 401 || response.status === 403) { - console.warn(`GitHub API rate limit or authorization issue: ${response.statusText}`) + console.warn( + `GitHub API rate limit or authorization issue: ${response.statusText}`, + ); return { login: "unknown", avatar_url: "https://avatars.githubusercontent.com/u/0", html_url: "https://github.com", name: "Unknown User", bio: null, - } + }; } - throw new ApiError(`Failed to fetch author data: ${response.statusText}`, response.status) + throw new ApiError( + `Failed to fetch author data: ${response.statusText}`, + response.status, + ); } - return response.json() + return response.json(); } catch (error) { - console.error("Error fetching author data:", error) + console.error("Error fetching author data:", error); // Even for unexpected errors, return a default user to prevent page failures return { login: "unknown", @@ -138,11 +152,11 @@ async function fetchAuthorData(authorId: number) { html_url: "https://github.com", name: "Unknown User", bio: null, - } + }; } } -const authorDataCache: Record = {} +const authorDataCache: Record = {}; /** * Cached version of fetchAuthorData @@ -155,12 +169,12 @@ const authorDataCache: Record = {} */ export async function getAuthorData(authorId: number): Promise { if (authorDataCache[authorId]) { - return authorDataCache[authorId] + return authorDataCache[authorId]; } - const data = await fetchAuthorData(authorId) - authorDataCache[authorId] = data - return data + const data = await fetchAuthorData(authorId); + authorDataCache[authorId] = data; + return data; } /** @@ -168,32 +182,37 @@ export async function getAuthorData(authorId: number): Promise { */ export async function getTotalIcons() { try { - const iconsData = await getAllIcons() + const iconsData = await getAllIcons(); return { totalIcons: Object.keys(iconsData).length, - } + }; } catch (error) { - console.error("Error getting total icons:", error) - throw error + console.error("Error getting total icons:", error); + throw error; } } /** * Fetches recently added icons sorted by timestamp */ -export async function getRecentlyAddedIcons(limit = 8): Promise { +export async function getRecentlyAddedIcons( + limit = 8, +): Promise { try { - const icons = await getIconsArray() + const icons = await getIconsArray(); return icons .sort((a, b) => { // Sort by timestamp in descending order (newest first) - return new Date(b.data.update.timestamp).getTime() - new Date(a.data.update.timestamp).getTime() + return ( + new Date(b.data.update.timestamp).getTime() - + new Date(a.data.update.timestamp).getTime() + ); }) - .slice(0, limit) + .slice(0, limit); } catch (error) { - console.error("Error getting recently added icons:", error) - throw error + console.error("Error getting recently added icons:", error); + throw error; } } From 8fc205f5a22f90bc19500c99e2c3e4893f7277ee Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 17 Nov 2025 14:50:38 +0100 Subject: [PATCH 3/3] chore: Try fixing compilation for the nth time --- web/package.json | 3 +- web/scripts/benchmark-og-images.tsx | 419 ------------------ .../app/community/[icon]/opengraph-image.tsx | 81 ++-- web/src/app/community/[icon]/page.tsx | 2 + web/src/app/icons/[icon]/opengraph-image.tsx | 128 +++--- web/src/app/icons/[icon]/page.tsx | 16 + web/src/lib/api.ts | 32 +- web/src/lib/icon-cache.ts | 76 ---- 8 files changed, 111 insertions(+), 646 deletions(-) delete mode 100644 web/scripts/benchmark-og-images.tsx delete mode 100644 web/src/lib/icon-cache.ts diff --git a/web/package.json b/web/package.json index 3789b19a..d719d360 100644 --- a/web/package.json +++ b/web/package.json @@ -13,8 +13,7 @@ "ci": "biome check --write", "backend:start": "cd backend && ./pocketbase serve", "backend:download": "cd backend && curl -L -o pocketbase.zip https://github.com/pocketbase/pocketbase/releases/download/v0.30.0/pocketbase_0.30.0_darwin_arm64.zip && unzip pocketbase.zip && rm pocketbase.zip && rm CHANGELOG.md && rm LICENSE.md", - "seed": "bun run seed-db.ts", - "benchmark:og": "bun run scripts/benchmark-og-images.tsx" + "seed": "bun run seed-db.ts" }, "dependencies": { "@hookform/resolvers": "^5.2.1", diff --git a/web/scripts/benchmark-og-images.tsx b/web/scripts/benchmark-og-images.tsx deleted file mode 100644 index 107cab02..00000000 --- a/web/scripts/benchmark-og-images.tsx +++ /dev/null @@ -1,419 +0,0 @@ -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; -import { ImageResponse } from "next/og"; -import React from "react"; -import { METADATA_URL } from "../src/constants"; -import type { IconFile } from "../src/types/icons"; - -// Standalone cached functions for benchmarking (no Next.js dependencies) -let iconsDataCache: IconFile | null = null; -const iconFileCache = new Map(); -let preloadDone = false; - -async function getAllIconsStandalone(): Promise { - if (iconsDataCache) { - return iconsDataCache; - } - const response = await fetch(METADATA_URL); - if (!response.ok) { - throw new Error(`Failed to fetch icons: ${response.statusText}`); - } - iconsDataCache = (await response.json()) as IconFile; - return iconsDataCache; -} - -async function preloadAllIconsStandalone(): Promise { - if (preloadDone) { - return; - } - const startTime = Date.now(); - const iconsData = await getAllIconsStandalone(); - const iconNames = Object.keys(iconsData); - const pngDir = join(process.cwd(), `../png`); - - console.log(`[Preload] Loading ${iconNames.length} icons into memory...`); - - const loadPromises = iconNames.map(async (iconName) => { - if (iconFileCache.has(iconName)) { - return; - } - try { - const iconPath = join(pngDir, `${iconName}.png`); - const buffer = await readFile(iconPath); - iconFileCache.set(iconName, buffer); - } catch (_error) { - iconFileCache.set(iconName, null); - } - }); - - await Promise.all(loadPromises); - const duration = Date.now() - startTime; - const loadedCount = Array.from(iconFileCache.values()).filter( - (v) => v !== null, - ).length; - console.log( - `[Preload] Loaded ${loadedCount}/${iconNames.length} icons in ${duration}ms (${(loadedCount / duration).toFixed(2)} icons/ms)\n`, - ); - preloadDone = true; -} - -async function readIconFileStandalone( - iconName: string, -): Promise { - if (iconFileCache.has(iconName)) { - return iconFileCache.get(iconName)!; - } - try { - const iconPath = join(process.cwd(), `../png/${iconName}.png`); - const buffer = await readFile(iconPath); - iconFileCache.set(iconName, buffer); - return buffer; - } catch (_error) { - iconFileCache.set(iconName, null); - return null; - } -} - -const size = { - width: 1200, - height: 630, -}; - -async function generateOGImage( - icon: string, - iconsData: Record, - totalIcons: number, - index: number, - profileTimings: Map, -) { - const stepTimings: Record = {}; - let stepStart: number; - - stepStart = Date.now(); - const formattedIconName = icon - .split("-") - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(" "); - stepTimings.formatName = Date.now() - stepStart; - - stepStart = Date.now(); - const iconData = await readIconFileStandalone(icon); - stepTimings.readFile = Date.now() - stepStart; - - stepStart = Date.now(); - const iconUrl = iconData - ? `data:image/png;base64,${iconData.toString("base64")}` - : null; - stepTimings.base64 = Date.now() - stepStart; - - stepStart = Date.now(); - const imageResponse = await new ImageResponse( -
-
-
-
-
-
- {iconUrl ? ( - {formattedIconName} - ) : ( -
- {formattedIconName} -
- )} -
-
-
- Download {formattedIconName} icon for free -
-
- Amongst {totalIcons} other high-quality dashboard icons -
-
- {["SVG", "PNG", "WEBP"].map((format) => ( -
- {format} -
- ))} -
-
-
-
-
-
- dashboardicons.com -
-
-
, - { - ...size, - }, - ); - stepTimings.imageResponse = Date.now() - stepStart; - - for (const [step, timing] of Object.entries(stepTimings)) { - if (!profileTimings.has(step)) { - profileTimings.set(step, []); - } - profileTimings.get(step)!.push(timing); - } - - return imageResponse; -} - -async function benchmark() { - console.log("Starting OG image generation benchmark...\n"); - - const startTime = Date.now(); - - console.log("Fetching icons data..."); - const iconsData = await getAllIconsStandalone(); - const iconNames = Object.keys(iconsData); - const totalIcons = iconNames.length; - const testIcons = iconNames.slice(0, 100); - - await preloadAllIconsStandalone(); - - console.log(`Testing with ${testIcons.length} icons\n`); - - const times: number[] = []; - const profileTimings = new Map(); - - for (let i = 0; i < testIcons.length; i++) { - const icon = testIcons[i]; - const iconStartTime = Date.now(); - - try { - await generateOGImage(icon, iconsData, totalIcons, i, profileTimings); - const iconEndTime = Date.now(); - const duration = iconEndTime - iconStartTime; - times.push(duration); - - if ((i + 1) % 10 === 0) { - const avgTime = times.slice(-10).reduce((a, b) => a + b, 0) / 10; - console.log( - `Generated ${i + 1}/${testIcons.length} images (avg: ${avgTime.toFixed(2)}ms per image)`, - ); - } - } catch (error) { - console.error(`Failed to generate image for ${icon}:`, error); - } - } - - const endTime = Date.now(); - const totalDuration = endTime - startTime; - const avgTime = times.reduce((a, b) => a + b, 0) / times.length; - const minTime = Math.min(...times); - const maxTime = Math.max(...times); - - console.log("\n" + "=".repeat(50)); - console.log("Benchmark Results"); - console.log("=".repeat(50)); - console.log(`Total images generated: ${testIcons.length}`); - console.log(`Total time: ${(totalDuration / 1000).toFixed(2)}s`); - console.log(`Average time per image: ${avgTime.toFixed(2)}ms`); - console.log(`Min time: ${minTime.toFixed(2)}ms`); - console.log(`Max time: ${maxTime.toFixed(2)}ms`); - console.log( - `Images per second: ${((testIcons.length / totalDuration) * 1000).toFixed(2)}`, - ); - console.log("\n" + "-".repeat(50)); - console.log("Performance Breakdown (per image):"); - console.log("-".repeat(50)); - for (const [step, timings] of profileTimings.entries()) { - const avg = timings.reduce((a, b) => a + b, 0) / timings.length; - const min = Math.min(...timings); - const max = Math.max(...timings); - const total = timings.reduce((a, b) => a + b, 0); - const percentage = ( - (total / times.reduce((a, b) => a + b, 0)) * - 100 - ).toFixed(1); - console.log( - ` ${step.padEnd(15)}: avg ${avg.toFixed(2)}ms | min ${min.toFixed(2)}ms | max ${max.toFixed(2)}ms | ${percentage}%`, - ); - } - console.log("=".repeat(50)); -} - -benchmark().catch(console.error); diff --git a/web/src/app/community/[icon]/opengraph-image.tsx b/web/src/app/community/[icon]/opengraph-image.tsx index 5b8913e5..94f17d72 100644 --- a/web/src/app/community/[icon]/opengraph-image.tsx +++ b/web/src/app/community/[icon]/opengraph-image.tsx @@ -2,26 +2,16 @@ import { permanentRedirect, redirect } from "next/navigation" import { ImageResponse } from "next/og" import { getCommunityGalleryRecord, getCommunitySubmissionByName, getCommunitySubmissions } from "@/lib/community" -export const revalidate = 21600 // 6 hours - -export async function generateStaticParams() { - const icons = await getCommunitySubmissions() - const validIcons = icons.filter((icon) => icon.name) - if (process.env.CI_MODE === "false") { - return validIcons.slice(0, 5).map((icon) => ({ - icon: icon.name, - })) - } - return validIcons.map((icon) => ({ - icon: icon.name, - })) -} +export const dynamic = "force-dynamic"; export const size = { width: 1200, height: 630, } +export const alt = "Community Icon Open Graph Image"; +export const contentType = "image/png"; + export default async function Image({ params }: { params: Promise<{ icon: string }> }) { const { icon } = await params @@ -135,7 +125,9 @@ export default async function Image({ params }: { params: Promise<{ icon: string } } - const iconUrl = iconDataBuffer ? `data:image/png;base64,${iconDataBuffer.toString("base64")}` : null + const iconUrl = iconDataBuffer + ? `data:image/png;base64,${iconDataBuffer.toString("base64")}` + : `https://placehold.co/600x400?text=${formattedIconName}`; return new ImageResponse(
- {iconUrl ? ( - {formattedIconName} - ) : ( -
- {formattedIconName} -
- )} + {formattedIconName}
({ - icon, - })) - } - return Object.keys(iconsData).map((icon) => ({ - icon, - })) -} +export const dynamic = "force-dynamic"; export const size = { width: 1200, height: 630, -} -export default async function Image({ params }: { params: Promise<{ icon: string }> }) { - const { icon } = await params +}; + +export const alt = "Icon Open Graph Image"; +export const contentType = "image/png"; +export default async function Image({ + params, +}: { + params: Promise<{ icon: string }>; +}) { + const { icon } = await params; if (!icon) { - console.error(`[Opengraph Image] Icon not found for ${icon}`) + console.error(`[Opengraph Image] Icon not found for ${icon}`); return new ImageResponse(
, { ...size }, - ) + ); } - await preloadAllIcons() - - const iconsData = await getAllIcons() - const totalIcons = Object.keys(iconsData).length - const index = Object.keys(iconsData).indexOf(icon) + const iconsData = await getAllIcons(); + const totalIcons = Object.keys(iconsData).length; + const index = Object.keys(iconsData).indexOf(icon); + // Format the icon name for display const formattedIconName = icon .split("-") .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(" ") + .join(" "); - const iconData = await readIconFile(icon) - const iconUrl = iconData ? `data:image/png;base64,${iconData.toString("base64")}` : null + // Read the icon file from local filesystem + let iconData: Buffer | null = null; + try { + const iconPath = join(process.cwd(), `../png/${icon}.png`); + console.log( + `Generating opengraph image for ${icon} (${index + 1} / ${totalIcons}) from path ${iconPath}`, + ); + iconData = await readFile(iconPath); + } catch (_error) { + console.error(`Icon ${icon} was not found locally`); + } + + // Convert the image data to a data URL or use placeholder + const iconUrl = iconData + ? `data:image/png;base64,${iconData.toString("base64")}` + : null; return new ImageResponse(
- {iconUrl ? ( - {formattedIconName} - ) : ( -
- {formattedIconName} -
- )} + {formattedIconName}
{/* Text content */} @@ -289,5 +281,5 @@ export default async function Image({ params }: { params: Promise<{ icon: string { ...size, }, - ) + ); } diff --git a/web/src/app/icons/[icon]/page.tsx b/web/src/app/icons/[icon]/page.tsx index 1151ba9e..9a87398a 100644 --- a/web/src/app/icons/[icon]/page.tsx +++ b/web/src/app/icons/[icon]/page.tsx @@ -79,11 +79,26 @@ export async function generateMetadata({ params, searchParams }: Props, _parent: url: pageUrl, siteName: "Dashboard Icons", images: [ + { + url: `${BASE_URL}/png/${icon}.png`, + width: 512, + height: 512, + alt: `${formattedIconName} icon`, + type: "image/png", + }, { url: `${BASE_URL}/webp/${icon}.webp`, width: 512, height: 512, alt: `${formattedIconName} icon`, + type: "image/webp", + }, + { + url: `${BASE_URL}/svg/${icon}.svg`, + width: 512, + height: 512, + alt: `${formattedIconName} icon`, + type: "image/svg+xml", }, ], }, @@ -91,6 +106,7 @@ export async function generateMetadata({ params, searchParams }: Props, _parent: card: "summary_large_image", title: `${formattedIconName} Icon | Dashboard Icons`, description: `Download the ${formattedIconName} icon in SVG, PNG, and WEBP formats for FREE. Part of a collection of ${totalIcons} curated icons for services, applications and tools, designed specifically for dashboards and app directories.`, + images: [`${BASE_URL}/png/${icon}.png`], }, alternates: { canonical: `${WEB_URL}/icons/${icon}`, diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 74016469..672e0d77 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1,17 +1,15 @@ import { unstable_cache } from "next/cache"; -import { cache } from "react"; import { METADATA_URL } from "@/constants"; import { ApiError } from "@/lib/errors"; import type { AuthorData, IconFile, IconWithName } from "@/types/icons"; /** - * Raw fetch function for icon data (without caching) + * Fetches all icon data from the metadata.json file + * Uses fetch with revalidate for caching */ -async function fetchAllIconsRaw(): Promise { +export async function getAllIcons(): Promise { try { - const response = await fetch(METADATA_URL, { - next: { revalidate: 3600 }, - }); + const response = await fetch(METADATA_URL); if (!response.ok) { throw new ApiError( @@ -30,28 +28,6 @@ async function fetchAllIconsRaw(): Promise { } } -/** - * Cached version using unstable_cache for build-time caching - * Revalidates every hour (3600 seconds) - */ -const getAllIconsCached = unstable_cache( - async () => fetchAllIconsRaw(), - ["all-icons"], - { - revalidate: 3600, - tags: ["icons"], - }, -); - -/** - * Fetches all icon data from the metadata.json file - * Uses React cache() for request-level memoization and unstable_cache for build-level caching - * This prevents duplicate fetches within the same request and across builds - */ -export const getAllIcons = cache(async (): Promise => { - return getAllIconsCached(); -}); - /** * Gets a list of all icon names. */ diff --git a/web/src/lib/icon-cache.ts b/web/src/lib/icon-cache.ts deleted file mode 100644 index 09bb582a..00000000 --- a/web/src/lib/icon-cache.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { readFile } from "node:fs/promises" -import { join } from "node:path" -import { cache } from "react" -import { getAllIcons } from "./api" - -/** - * In-memory cache for icon files during build/request - * This persists across multiple calls within the same build process - */ -const iconFileCache = new Map() -let preloadPromise: Promise | null = null -let isPreloaded = false - -/** - * Preloads all icon files into memory - * This should be called once at the start of the build process - */ -export async function preloadAllIcons(): Promise { - if (isPreloaded || preloadPromise) { - return preloadPromise || Promise.resolve() - } - - preloadPromise = (async () => { - const startTime = Date.now() - const iconsData = await getAllIcons() - const iconNames = Object.keys(iconsData) - const pngDir = join(process.cwd(), `../png`) - - console.log(`[Icon Cache] Preloading ${iconNames.length} icons into memory...`) - - const loadPromises = iconNames.map(async (iconName) => { - if (iconFileCache.has(iconName)) { - return - } - try { - const iconPath = join(pngDir, `${iconName}.png`) - const buffer = await readFile(iconPath) - iconFileCache.set(iconName, buffer) - } catch (_error) { - iconFileCache.set(iconName, null) - } - }) - - await Promise.all(loadPromises) - const duration = Date.now() - startTime - const loadedCount = Array.from(iconFileCache.values()).filter((v) => v !== null).length - console.log( - `[Icon Cache] Preloaded ${loadedCount}/${iconNames.length} icons in ${duration}ms (${(loadedCount / duration).toFixed(2)} icons/ms)`, - ) - isPreloaded = true - })() - - return preloadPromise -} - -/** - * Reads an icon PNG file from the filesystem - * Uses React cache() for request-level memoization - * Uses in-memory Map for build-level caching - * If preloaded, returns immediately from cache - */ -export const readIconFile = cache(async (iconName: string): Promise => { - if (iconFileCache.has(iconName)) { - return iconFileCache.get(iconName)! - } - - try { - const iconPath = join(process.cwd(), `../png/${iconName}.png`) - const buffer = await readFile(iconPath) - iconFileCache.set(iconName, buffer) - return buffer - } catch (_error) { - iconFileCache.set(iconName, null) - return null - } -})