perf: og image caching v2 (#23189)

This commit is contained in:
Benny Joo
2025-10-29 10:36:17 -03:00
committed by GitHub
parent 8ba65c4450
commit 14e845ea1f
8 changed files with 251 additions and 99 deletions
@@ -17,21 +17,21 @@ export const generateMetadata = async ({ params }: _PageProps) => {
if (!p.success) {
return notFound();
}
const props = await getStaticProps(p.data.slug);
const slugFromUrl = p.data.slug;
const props = await getStaticProps(slugFromUrl);
if (!props) {
notFound();
}
const { name, logo, description } = props.data;
const { name, logo, dirName: appStoreDirSlug, slug: appSlug, description } = props.data;
return await generateAppMetadata(
{ slug: logo, name, description },
{ slug: appStoreDirSlug ?? appSlug, logoUrl: logo, name, description },
() => name,
() => description,
undefined,
undefined,
`/apps/${p.data.slug}`
`/apps/${appSlug}`
);
};
+7 -7
View File
@@ -66,10 +66,10 @@ export const _generateMetadata = async (
);
const image =
SEO_IMG_OGIMG +
constructGenericImage({
(await constructGenericImage({
title: metadata.title,
description: metadata.description,
});
}));
return {
...metadata,
@@ -80,7 +80,7 @@ export const _generateMetadata = async (
};
};
export const _generateMetadataForStaticPage = (
export const _generateMetadataForStaticPage = async (
title: string,
description: string,
hideBranding?: boolean,
@@ -108,10 +108,10 @@ export const _generateMetadataForStaticPage = (
};
const image =
SEO_IMG_OGIMG +
constructGenericImage({
(await constructGenericImage({
title: metadata.title,
description: metadata.description,
});
}));
return {
...metadata,
@@ -137,7 +137,7 @@ export const generateMeetingMetadata = async (
origin,
pathname
);
const image = SEO_IMG_OGIMG + constructMeetingImage(meeting);
const image = SEO_IMG_OGIMG + (await constructMeetingImage(meeting));
return {
...metadata,
@@ -164,7 +164,7 @@ export const generateAppMetadata = async (
pathname
);
const image = SEO_IMG_OGIMG + constructAppImage({ ...app, description: metadata.description });
const image = SEO_IMG_OGIMG + (await constructAppImage({ ...app, description: metadata.description }));
return {
...metadata,
@@ -1,6 +1,8 @@
import { NextRequest } from "next/server";
import { describe, expect, test, vi, beforeEach } from "vitest";
import { getOGImageVersion } from "@calcom/lib/OgImages";
import { GET } from "../route";
vi.mock("next/og", () => ({
@@ -14,14 +16,19 @@ vi.mock("next/og", () => ({
})),
}));
vi.mock("@calcom/lib/OgImages", () => ({
Meeting: vi.fn(() => null),
App: vi.fn(() => null),
Generic: vi.fn(() => null),
}));
vi.mock("@calcom/lib/OgImages", async (importOriginal) => {
return await importOriginal();
});
vi.mock("@calcom/lib/constants", () => ({
WEBAPP_URL: "http://localhost:3000",
vi.mock(import("@calcom/lib/constants"), async (importOriginal) => {
return await importOriginal();
});
vi.mock("@calcom/web/public/app-store/svg-hashes.json", () => ({
default: {
huddle01video: "81a0653b",
zoomvideo: "d1c78abf",
},
}));
global.fetch = vi.fn();
@@ -82,7 +89,7 @@ describe("GET /api/social/og/image", () => {
const response = await GET(request);
expect(response.status).toBe(404);
expect(await response.text()).toBe("What you're looking for is not here..");
expect(await response.text()).toBe("Wrong image type");
});
test("returns 404 when invalid type parameter is provided", async () => {
@@ -90,7 +97,7 @@ describe("GET /api/social/og/image", () => {
const response = await GET(request);
expect(response.status).toBe(404);
expect(await response.text()).toBe("What you're looking for is not here..");
expect(await response.text()).toBe("Wrong image type");
});
});
@@ -107,4 +114,33 @@ describe("GET /api/social/og/image", () => {
expect(await response.text()).toBe("Internal server error");
});
});
describe("getOGImageVersion with SVG hash", () => {
test("app type: ETag changes when SVG hash is provided", async () => {
const etagWithoutHash = await getOGImageVersion("app");
const etagWithHash = await getOGImageVersion("app", {
slug: "huddle01video",
svgHash: "81a0653b",
});
expect(etagWithoutHash).toBeTruthy();
expect(etagWithHash).toBeTruthy();
expect(etagWithHash).not.toBe(etagWithoutHash);
});
test("app type: different SVG hashes produce different ETags", async () => {
const etagHash1 = await getOGImageVersion("app", {
slug: "huddle01video",
svgHash: "81a0653b",
});
const etagHash2 = await getOGImageVersion("app", {
slug: "zoomvideo",
svgHash: "d1c78abf",
});
expect(etagHash1).toBeTruthy();
expect(etagHash2).toBeTruthy();
expect(etagHash1).not.toBe(etagHash2);
});
});
});
+28 -8
View File
@@ -3,7 +3,7 @@ import type { NextRequest } from "next/server";
import type { SatoriOptions } from "satori";
import { z, ZodError } from "zod";
import { Meeting, App, Generic } from "@calcom/lib/OgImages";
import { Meeting, App, Generic, getOGImageVersion } from "@calcom/lib/OgImages";
import { WEBAPP_URL } from "@calcom/lib/constants";
export const runtime = "edge";
@@ -22,6 +22,7 @@ const appSchema = z.object({
name: z.string(),
description: z.string(),
slug: z.string(),
logoUrl: z.string(),
});
const genericSchema = z.object({
@@ -74,6 +75,7 @@ async function handler(req: NextRequest) {
imageType,
});
const etag = await getOGImageVersion("meeting");
const img = new ImageResponse(
(
<Meeting
@@ -89,7 +91,9 @@ async function handler(req: NextRequest) {
status: 200,
headers: {
"Content-Type": "image/png",
"Cache-Control": "public, max-age=3600, stale-while-revalidate=86400",
"Cache-Control":
"public, max-age=31536000, immutable, s-maxage=31536000, stale-while-revalidate=31536000",
ETag: `"${etag}"`,
},
});
} catch (error) {
@@ -111,19 +115,32 @@ async function handler(req: NextRequest) {
}
case "app": {
try {
const { name, description, slug } = appSchema.parse({
const { name, description, slug, logoUrl } = appSchema.parse({
name: searchParams.get("name"),
description: searchParams.get("description"),
slug: searchParams.get("slug"),
logoUrl: searchParams.get("logoUrl"),
imageType,
});
const img = new ImageResponse(<App name={name} description={description} slug={slug} />, ogConfig);
// Get SVG hash for the app
const svgHashesModule = await import("@calcom/web/public/app-store/svg-hashes.json");
const SVG_HASHES = svgHashesModule.default ?? {};
const svgHash = SVG_HASHES[slug] ?? undefined;
const etag = await getOGImageVersion("app", { svgHash });
const img = new ImageResponse(
<App name={name} description={description} slug={slug} logoUrl={logoUrl} />,
ogConfig
);
return new Response(img.body, {
status: 200,
headers: {
"Content-Type": "image/png",
"Cache-Control": "public, max-age=3600, stale-while-revalidate=86400",
"Cache-Control":
"public, max-age=31536000, immutable, s-maxage=31536000, stale-while-revalidate=31536000",
ETag: `"${etag}"`,
},
});
} catch (error) {
@@ -151,13 +168,16 @@ async function handler(req: NextRequest) {
imageType,
});
const etag = await getOGImageVersion("generic");
const img = new ImageResponse(<Generic title={title} description={description} />, ogConfig);
return new Response(img.body, {
status: 200,
headers: {
"Content-Type": "image/png",
"Cache-Control": "public, max-age=3600, stale-while-revalidate=86400",
"Cache-Control":
"public, max-age=31536000, immutable, s-maxage=31536000, stale-while-revalidate=31536000",
ETag: `"${etag}"`,
},
});
} catch (error) {
@@ -178,9 +198,9 @@ async function handler(req: NextRequest) {
}
default:
return new Response("What you're looking for is not here..", { status: 404 });
return new Response("Wrong image type", { status: 404 });
}
} catch (error) {
} catch {
return new Response("Internal server error", { status: 500 });
}
}
+4
View File
@@ -0,0 +1,4 @@
declare module "@calcom/web/public/app-store/svg-hashes.json" {
const value: Record<string, string>;
export default value;
}
+4 -7
View File
@@ -10,13 +10,10 @@ import { lucideIconList } from "../../../../packages/ui/components/icon/icon-lis
import { IconGrid } from "./IconGrid";
export const dynamic = "force-static";
export const metadata: Metadata = _generateMetadataForStaticPage(
"Icons Showcase",
"",
undefined,
undefined,
"/icons"
);
export async function generateMetadata(): Promise<Metadata> {
return await _generateMetadataForStaticPage("Icons Showcase", "", undefined, undefined, "/icons");
}
const interFont = Inter({ subsets: ["latin"], variable: "--font-inter", preload: true, display: "swap" });
const calFont = localFont({
+18 -3
View File
@@ -1,21 +1,24 @@
const fs = require("fs");
const path = require("path");
const glob = require("glob");
const crypto = require("crypto");
const copyAppStoreStatic = () => {
// Get all static files from app-store packages
const staticFiles = glob.sync("../../packages/app-store/**/static/**/*", { nodir: true });
// Object to store icon SVG hashes
const SVG_HASHES = {};
staticFiles.forEach((file) => {
// Extract app name from path
const appNameMatch = file.match(/app-store\/(.*?)\/static/);
if (!appNameMatch) return;
const appName = appNameMatch[1];
const appDirName = appNameMatch[1];
const fileName = path.basename(file);
// Create destination directory if it doesn't exist
const destDir = path.join(process.cwd(), "public", "app-store", appName);
const destDir = path.join(process.cwd(), "public", "app-store", appDirName);
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true });
}
@@ -23,8 +26,20 @@ const copyAppStoreStatic = () => {
// Copy file to destination (Turborepo caching handles change detection)
const destPath = path.join(destDir, fileName);
fs.copyFileSync(file, destPath);
// If it's an icon SVG file, compute hash
if (fileName.includes("icon") && fileName.endsWith(".svg")) {
const content = fs.readFileSync(file, "utf8");
const hash = crypto.createHash("md5").update(content).digest("hex").slice(0, 8);
SVG_HASHES[appDirName] = hash;
}
console.log(`Copied ${file} to ${destPath}`);
});
// Write SVG hashes to a JSON file
const hashFilePath = path.join(process.cwd(), "public", "app-store", "svg-hashes.json");
fs.writeFileSync(hashFilePath, JSON.stringify(SVG_HASHES, null, 2));
};
// Run the copy function
+140 -60
View File
@@ -20,6 +20,7 @@ export interface AppImageProps {
name: string;
description: string;
slug: string;
logoUrl: string;
}
export interface GenericImageProps {
@@ -48,6 +49,48 @@ const joinMultipleNames = (names: string[] = []) => {
const makeAbsoluteUrl = (url: string) => (/^https?:\/\//.test(url) ? url : `${CAL_URL}${url}`);
const OG_ASSETS = {
meeting: {
id: "meeting-og-image-v1", // Bump version when changing Meeting component structure/styling
logo: LOGO,
logoWidth: "350",
avatarSize: "160",
variant: "dark" as const,
},
app: {
id: "app-og-image-v1", // Bump version when changing App component structure/styling
logo: LOGO,
logoWidth: "150",
iconSize: "172",
variant: "light" as const,
},
generic: {
id: "generic-og-image-v1", // Bump version when changing Generic component structure/styling
logo: "cal-logo-word-black.svg",
logoWidth: "350",
variant: "light" as const,
},
};
export const getOGImageVersion = async (
type: keyof typeof OG_ASSETS,
additionalInputs?: Record<string, string>
) => {
const versionInputs: Record<string, unknown> = {
...OG_ASSETS[type],
...(additionalInputs ?? {}),
};
const content = JSON.stringify(versionInputs, Object.keys(versionInputs).sort());
// Use Web Crypto API instead of Node.js crypto for Edge Runtime compatibility (`/api/social/og/image` is an Edge Runtime route)
const hashBuffer = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(content));
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
return hashHex.substring(0, 8);
};
/**
* Test urls:
* 1. 1 user http://localhost:3000/api/social/og/image?type=meeting&title=super%20long%20event%20title%20for%20testing%20purposes&meetingProfileName=Pro%20Example&meetingImage=http://localhost:3000/pro/avatar.png&names=Pro%20Example&usernames=pro
@@ -56,7 +99,11 @@ const makeAbsoluteUrl = (url: string) => (/^https?:\/\//.test(url) ? url : `${CA
* 4. Team event (round robin) http://localhost:3000/api/social/og/image?type=meeting&title=Round%20Robin%20Seeded%20Team%20Event&meetingProfileName=Seeded%20Team
* 5. Dynamic collective (2 persons) http://localhost:3000/api/social/og/image?type=meeting&title=15min&meetingProfileName=Team%20Pro%20Example,%20Pro%20Example&names=Team%20Pro%20Example&names=Pro%20Example&usernames=teampro&usernames=pro
*/
export const constructMeetingImage = ({ title, users = [], profile }: MeetingImageProps): string => {
export const constructMeetingImage = async ({
title,
users = [],
profile,
}: MeetingImageProps): Promise<string> => {
const params = new URLSearchParams({
type: "meeting",
title,
@@ -72,6 +119,8 @@ export const constructMeetingImage = ({ title, users = [], profile }: MeetingIma
params.append("usernames", user.username);
});
params.set("v", await getOGImageVersion("meeting"));
return encodeURIComponent(`/api/social/og/image?${params.toString()}`);
};
@@ -79,22 +128,34 @@ export const constructMeetingImage = ({ title, users = [], profile }: MeetingIma
* Test url:
* http://localhost:3000/api/social/og/image?type=app&name=Huddle01&slug=/api/app-store/huddle01video/icon.svg&description=Huddle01%20is%20a%20new%20video%20conferencing%20software%20native%20to%20Web3%20and%20is%20comparable%20to%20a%20decentralized%20version%20of%20Zoom.%20It%20supports%20conversations%20for...
*/
export const constructAppImage = ({ name, slug, description }: AppImageProps): string => {
export const constructAppImage = async ({
name,
slug,
logoUrl,
description,
}: AppImageProps): Promise<string> => {
const params = new URLSearchParams({
type: "app",
name,
slug,
description,
logoUrl,
});
params.set("v", await getOGImageVersion("app"));
return encodeURIComponent(`/api/social/og/image?${params.toString()}`);
};
export const constructGenericImage = ({ title, description }: GenericImageProps) => {
export const constructGenericImage = async ({ title, description }: GenericImageProps): Promise<string> => {
const params = new URLSearchParams({
type: "generic",
title,
description,
});
params.set("v", await getOGImageVersion("generic"));
return encodeURIComponent(`/api/social/og/image?${params.toString()}`);
};
@@ -112,7 +173,14 @@ const Wrapper = ({ children, variant = "light", rotateBackground }: WrapperProps
</div>
);
/**
* ⚠️ IMPORTANT: When modifying this component's structure, styling, or visual output,
* remember to bump the version in OG_ASSETS.meeting.id (e.g., "meeting-og-image-v1" → "meeting-og-image-v2")
* to ensure proper cache invalidation.
*/
export const Meeting = ({ title, users = [], profile }: MeetingImageProps) => {
const config = OG_ASSETS.meeting;
// We filter attendees here based on whether they have an image and filter duplicates.
// Users ALWAYS have an image (albeit a gray empty person avatar), so this mainly filters out
// any non existing images for dynamic collectives, while at the same time removing them from
@@ -134,10 +202,10 @@ export const Meeting = ({ title, users = [], profile }: MeetingImageProps) => {
const names = attendees.length > 0 ? attendees.map((user) => user.name) : [profile.name];
return (
<Wrapper variant="dark">
<Wrapper variant={config.variant}>
<div tw="h-full flex flex-col justify-start">
<div tw="flex items-center justify-center" style={{ fontFamily: "cal", fontWeight: 300 }}>
<img src={`${WEBAPP_URL}/${LOGO}`} width="350" alt="Logo" />
<img src={`${WEBAPP_URL}/${config.logo}`} width={config.logoWidth} alt="Logo" />
{avatars.length > 0 && (
<div style={{ color: "#111827" }} tw="font-bold text-[92px] mx-8 bottom-2">
/
@@ -150,12 +218,13 @@ export const Meeting = ({ title, users = [], profile }: MeetingImageProps) => {
key={avatar}
src={avatar}
alt="Profile picture"
width="160"
height="160"
width={config.avatarSize}
height={config.avatarSize}
/>
))}
{avatars.length > 3 && (
<div tw="flex items-center justify-center w-[160px] h-[160px] rounded-full bg-black text-inverted text-[54px] font-bold">
<div
tw={`flex items-center justify-center w-[${config.avatarSize}px] h-[${config.avatarSize}px] rounded-full bg-black text-inverted text-[54px] font-bold`}>
<span tw="flex top-[-5px] left-[-5px]">+{avatars.length - 3}</span>
</div>
)}
@@ -178,10 +247,10 @@ export const Meeting = ({ title, users = [], profile }: MeetingImageProps) => {
);
};
const VisualBlur = ({ visualSlug }: { visualSlug: string }) => {
const VisualBlur = ({ logoUrl }: { logoUrl: string }) => {
// Making a blur of a dark logo is very ugly. We use the filename to indicate,
// when we don't want to render these blurry blob backgrounds.
if (visualSlug.indexOf("dark") > -1) return null;
if (logoUrl.indexOf("dark") > -1) return null;
return (
<div tw="flex relative">
@@ -191,7 +260,7 @@ const VisualBlur = ({ visualSlug }: { visualSlug: string }) => {
style={{
filter: "blur(98px)",
backgroundColor: "rgba(255, 255, 255, 0.7)",
backgroundImage: `url(${WEBAPP_URL}${visualSlug})`,
backgroundImage: `url(${WEBAPP_URL}${logoUrl})`,
backgroundSize: "400px 400px",
}}
/>
@@ -202,7 +271,7 @@ const VisualBlur = ({ visualSlug }: { visualSlug: string }) => {
style={{
filter: "blur(150px)",
backgroundColor: "rgba(255, 255, 255, 0.7)",
backgroundImage: `url(${WEBAPP_URL}${visualSlug})`,
backgroundImage: `url(${WEBAPP_URL}${logoUrl})`,
backgroundSize: "630px 630px",
}}
/>
@@ -210,60 +279,71 @@ const VisualBlur = ({ visualSlug }: { visualSlug: string }) => {
);
};
export const App = ({ name, description, slug }: AppImageProps) => (
<Wrapper>
<img src={`${WEBAPP_URL}/${LOGO}`} width="150" alt="Logo" tw="absolute right-[48px] top-[48px]" />
/**
* ⚠️ IMPORTANT: When modifying this component's structure, styling, or visual output,
* remember to bump the version in OG_ASSETS.app.id (e.g., "app-og-image-v1" → "app-og-image-v2")
* to ensure proper cache invalidation.
*/
export const App = ({ name, description, logoUrl }: AppImageProps) => {
const config = OG_ASSETS.app;
<VisualBlur visualSlug={slug} />
return (
<Wrapper variant={config.variant}>
<img
src={`${WEBAPP_URL}/${config.logo}`}
width={config.logoWidth}
alt="Logo"
tw="absolute right-[48px] top-[48px]"
/>
<div tw="flex items-center w-full">
<div tw="flex">
<img src={`${WEBAPP_URL}${slug}`} alt="App icon" width="172" height="172" />
</div>
</div>
<div style={{ color: "#111827" }} tw="flex mt-auto w-full flex-col">
<div tw="flex text-[64px] mb-7" style={{ fontFamily: "cal", fontWeight: 600 }}>
{name}
</div>
<div tw="flex text-[36px]" style={{ fontFamily: "inter" }}>
{description}
</div>
</div>
</Wrapper>
);
<VisualBlur logoUrl={logoUrl} />
export const Generic = ({ title, description }: GenericImageProps) => (
<Wrapper>
<div tw="h-full flex flex-col justify-start">
<div tw="flex items-center justify-center" style={{ fontFamily: "cal", fontWeight: 300 }}>
<img src={`${WEBAPP_URL}/cal-logo-word-black.svg`} width="350" alt="Logo" />
</div>
<div style={{ color: "#111827" }} tw="relative flex text-[54px] w-full flex-col mt-auto">
<div tw="flex w-[1040px]" style={{ fontFamily: "cal" }}>
{title}
<div tw="flex items-center w-full">
<div tw="flex">
<img
src={`${WEBAPP_URL}${logoUrl}`}
alt="App icon"
width={config.iconSize}
height={config.iconSize}
/>
</div>
<div tw="flex mt-3 w-[1040px]" style={{ fontFamily: "inter" }}>
</div>
<div style={{ color: "#111827" }} tw="flex mt-auto w-full flex-col">
<div tw="flex text-[64px] mb-7" style={{ fontFamily: "cal", fontWeight: 600 }}>
{name}
</div>
<div tw="flex text-[36px]" style={{ fontFamily: "inter" }}>
{description}
</div>
</div>
</div>
</Wrapper>
);
</Wrapper>
);
};
export const ScreenShot = ({ image, fallbackImage }: ScreenshotImageProps) => (
<Wrapper rotateBackground>
<div tw="relative h-full w-full flex flex-col justify-center items-center">
<div tw="relative mt-[140px] flex rounded-2xl" style={{ boxShadow: "0 0 45px -3px rgba(0,0,0,.3)" }}>
<img
src={fallbackImage}
tw="absolute inset-0 rounded-2xl"
width="1024"
height="576"
alt="screenshot"
/>
<img src={image} width="1024" height="576" tw="rounded-2xl" alt="screenshot" />
/**
* ⚠️ IMPORTANT: When modifying this component's structure, styling, or visual output,
* remember to bump the version in OG_ASSETS.generic.id (e.g., "generic-og-image-v1" → "generic-og-image-v2")
* to ensure proper cache invalidation.
*/
export const Generic = ({ title, description }: GenericImageProps) => {
const config = OG_ASSETS.generic;
return (
<Wrapper variant={config.variant}>
<div tw="h-full flex flex-col justify-start">
<div tw="flex items-center justify-center" style={{ fontFamily: "cal", fontWeight: 300 }}>
<img src={`${WEBAPP_URL}/${config.logo}`} width={config.logoWidth} alt="Logo" />
</div>
<div style={{ color: "#111827" }} tw="relative flex text-[54px] w-full flex-col mt-auto">
<div tw="flex w-[1040px]" style={{ fontFamily: "cal" }}>
{title}
</div>
<div tw="flex mt-3 w-[1040px]" style={{ fontFamily: "inter" }}>
{description}
</div>
</div>
</div>
</div>
</Wrapper>
);
</Wrapper>
);
};