From 4b0a9313a577c37fd76d17c8b32d133a0689f669 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 17 Nov 2025 13:35:48 +0100 Subject: [PATCH] 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 + } +})