Skip to content

Commit 14e845e

Browse files
authored
perf: og image caching v2 (calcom#23189)
1 parent 8ba65c4 commit 14e845e

8 files changed

Lines changed: 250 additions & 98 deletions

File tree

apps/web/app/(use-page-wrapper)/apps/[slug]/page.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,21 +17,21 @@ export const generateMetadata = async ({ params }: _PageProps) => {
1717
if (!p.success) {
1818
return notFound();
1919
}
20-
21-
const props = await getStaticProps(p.data.slug);
20+
const slugFromUrl = p.data.slug;
21+
const props = await getStaticProps(slugFromUrl);
2222

2323
if (!props) {
2424
notFound();
2525
}
26-
const { name, logo, description } = props.data;
26+
const { name, logo, dirName: appStoreDirSlug, slug: appSlug, description } = props.data;
2727

2828
return await generateAppMetadata(
29-
{ slug: logo, name, description },
29+
{ slug: appStoreDirSlug ?? appSlug, logoUrl: logo, name, description },
3030
() => name,
3131
() => description,
3232
undefined,
3333
undefined,
34-
`/apps/${p.data.slug}`
34+
`/apps/${appSlug}`
3535
);
3636
};
3737

apps/web/app/_utils.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,10 @@ export const _generateMetadata = async (
6666
);
6767
const image =
6868
SEO_IMG_OGIMG +
69-
constructGenericImage({
69+
(await constructGenericImage({
7070
title: metadata.title,
7171
description: metadata.description,
72-
});
72+
}));
7373

7474
return {
7575
...metadata,
@@ -80,7 +80,7 @@ export const _generateMetadata = async (
8080
};
8181
};
8282

83-
export const _generateMetadataForStaticPage = (
83+
export const _generateMetadataForStaticPage = async (
8484
title: string,
8585
description: string,
8686
hideBranding?: boolean,
@@ -108,10 +108,10 @@ export const _generateMetadataForStaticPage = (
108108
};
109109
const image =
110110
SEO_IMG_OGIMG +
111-
constructGenericImage({
111+
(await constructGenericImage({
112112
title: metadata.title,
113113
description: metadata.description,
114-
});
114+
}));
115115

116116
return {
117117
...metadata,
@@ -137,7 +137,7 @@ export const generateMeetingMetadata = async (
137137
origin,
138138
pathname
139139
);
140-
const image = SEO_IMG_OGIMG + constructMeetingImage(meeting);
140+
const image = SEO_IMG_OGIMG + (await constructMeetingImage(meeting));
141141

142142
return {
143143
...metadata,
@@ -164,7 +164,7 @@ export const generateAppMetadata = async (
164164
pathname
165165
);
166166

167-
const image = SEO_IMG_OGIMG + constructAppImage({ ...app, description: metadata.description });
167+
const image = SEO_IMG_OGIMG + (await constructAppImage({ ...app, description: metadata.description }));
168168

169169
return {
170170
...metadata,

apps/web/app/api/social/og/image/__tests__/route.test.ts

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { NextRequest } from "next/server";
22
import { describe, expect, test, vi, beforeEach } from "vitest";
33

4+
import { getOGImageVersion } from "@calcom/lib/OgImages";
5+
46
import { GET } from "../route";
57

68
vi.mock("next/og", () => ({
@@ -14,14 +16,19 @@ vi.mock("next/og", () => ({
1416
})),
1517
}));
1618

17-
vi.mock("@calcom/lib/OgImages", () => ({
18-
Meeting: vi.fn(() => null),
19-
App: vi.fn(() => null),
20-
Generic: vi.fn(() => null),
21-
}));
19+
vi.mock("@calcom/lib/OgImages", async (importOriginal) => {
20+
return await importOriginal();
21+
});
22+
23+
vi.mock(import("@calcom/lib/constants"), async (importOriginal) => {
24+
return await importOriginal();
25+
});
2226

23-
vi.mock("@calcom/lib/constants", () => ({
24-
WEBAPP_URL: "http://localhost:3000",
27+
vi.mock("@calcom/web/public/app-store/svg-hashes.json", () => ({
28+
default: {
29+
huddle01video: "81a0653b",
30+
zoomvideo: "d1c78abf",
31+
},
2532
}));
2633

2734
global.fetch = vi.fn();
@@ -82,15 +89,15 @@ describe("GET /api/social/og/image", () => {
8289
const response = await GET(request);
8390

8491
expect(response.status).toBe(404);
85-
expect(await response.text()).toBe("What you're looking for is not here..");
92+
expect(await response.text()).toBe("Wrong image type");
8693
});
8794

8895
test("returns 404 when invalid type parameter is provided", async () => {
8996
const request = createNextRequest("http://example.com/api/social/og/image?type=invalid");
9097
const response = await GET(request);
9198

9299
expect(response.status).toBe(404);
93-
expect(await response.text()).toBe("What you're looking for is not here..");
100+
expect(await response.text()).toBe("Wrong image type");
94101
});
95102
});
96103

@@ -107,4 +114,33 @@ describe("GET /api/social/og/image", () => {
107114
expect(await response.text()).toBe("Internal server error");
108115
});
109116
});
117+
118+
describe("getOGImageVersion with SVG hash", () => {
119+
test("app type: ETag changes when SVG hash is provided", async () => {
120+
const etagWithoutHash = await getOGImageVersion("app");
121+
const etagWithHash = await getOGImageVersion("app", {
122+
slug: "huddle01video",
123+
svgHash: "81a0653b",
124+
});
125+
126+
expect(etagWithoutHash).toBeTruthy();
127+
expect(etagWithHash).toBeTruthy();
128+
expect(etagWithHash).not.toBe(etagWithoutHash);
129+
});
130+
131+
test("app type: different SVG hashes produce different ETags", async () => {
132+
const etagHash1 = await getOGImageVersion("app", {
133+
slug: "huddle01video",
134+
svgHash: "81a0653b",
135+
});
136+
const etagHash2 = await getOGImageVersion("app", {
137+
slug: "zoomvideo",
138+
svgHash: "d1c78abf",
139+
});
140+
141+
expect(etagHash1).toBeTruthy();
142+
expect(etagHash2).toBeTruthy();
143+
expect(etagHash1).not.toBe(etagHash2);
144+
});
145+
});
110146
});

apps/web/app/api/social/og/image/route.tsx

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { NextRequest } from "next/server";
33
import type { SatoriOptions } from "satori";
44
import { z, ZodError } from "zod";
55

6-
import { Meeting, App, Generic } from "@calcom/lib/OgImages";
6+
import { Meeting, App, Generic, getOGImageVersion } from "@calcom/lib/OgImages";
77
import { WEBAPP_URL } from "@calcom/lib/constants";
88

99
export const runtime = "edge";
@@ -22,6 +22,7 @@ const appSchema = z.object({
2222
name: z.string(),
2323
description: z.string(),
2424
slug: z.string(),
25+
logoUrl: z.string(),
2526
});
2627

2728
const genericSchema = z.object({
@@ -74,6 +75,7 @@ async function handler(req: NextRequest) {
7475
imageType,
7576
});
7677

78+
const etag = await getOGImageVersion("meeting");
7779
const img = new ImageResponse(
7880
(
7981
<Meeting
@@ -89,7 +91,9 @@ async function handler(req: NextRequest) {
8991
status: 200,
9092
headers: {
9193
"Content-Type": "image/png",
92-
"Cache-Control": "public, max-age=3600, stale-while-revalidate=86400",
94+
"Cache-Control":
95+
"public, max-age=31536000, immutable, s-maxage=31536000, stale-while-revalidate=31536000",
96+
ETag: `"${etag}"`,
9397
},
9498
});
9599
} catch (error) {
@@ -111,19 +115,32 @@ async function handler(req: NextRequest) {
111115
}
112116
case "app": {
113117
try {
114-
const { name, description, slug } = appSchema.parse({
118+
const { name, description, slug, logoUrl } = appSchema.parse({
115119
name: searchParams.get("name"),
116120
description: searchParams.get("description"),
117121
slug: searchParams.get("slug"),
122+
logoUrl: searchParams.get("logoUrl"),
118123
imageType,
119124
});
120-
const img = new ImageResponse(<App name={name} description={description} slug={slug} />, ogConfig);
125+
126+
// Get SVG hash for the app
127+
const svgHashesModule = await import("@calcom/web/public/app-store/svg-hashes.json");
128+
const SVG_HASHES = svgHashesModule.default ?? {};
129+
const svgHash = SVG_HASHES[slug] ?? undefined;
130+
131+
const etag = await getOGImageVersion("app", { svgHash });
132+
const img = new ImageResponse(
133+
<App name={name} description={description} slug={slug} logoUrl={logoUrl} />,
134+
ogConfig
135+
);
121136

122137
return new Response(img.body, {
123138
status: 200,
124139
headers: {
125140
"Content-Type": "image/png",
126-
"Cache-Control": "public, max-age=3600, stale-while-revalidate=86400",
141+
"Cache-Control":
142+
"public, max-age=31536000, immutable, s-maxage=31536000, stale-while-revalidate=31536000",
143+
ETag: `"${etag}"`,
127144
},
128145
});
129146
} catch (error) {
@@ -151,13 +168,16 @@ async function handler(req: NextRequest) {
151168
imageType,
152169
});
153170

171+
const etag = await getOGImageVersion("generic");
154172
const img = new ImageResponse(<Generic title={title} description={description} />, ogConfig);
155173

156174
return new Response(img.body, {
157175
status: 200,
158176
headers: {
159177
"Content-Type": "image/png",
160-
"Cache-Control": "public, max-age=3600, stale-while-revalidate=86400",
178+
"Cache-Control":
179+
"public, max-age=31536000, immutable, s-maxage=31536000, stale-while-revalidate=31536000",
180+
ETag: `"${etag}"`,
161181
},
162182
});
163183
} catch (error) {
@@ -178,9 +198,9 @@ async function handler(req: NextRequest) {
178198
}
179199

180200
default:
181-
return new Response("What you're looking for is not here..", { status: 404 });
201+
return new Response("Wrong image type", { status: 404 });
182202
}
183-
} catch (error) {
203+
} catch {
184204
return new Response("Internal server error", { status: 500 });
185205
}
186206
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
declare module "@calcom/web/public/app-store/svg-hashes.json" {
2+
const value: Record<string, string>;
3+
export default value;
4+
}

apps/web/app/icons/page.tsx

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,10 @@ import { lucideIconList } from "../../../../packages/ui/components/icon/icon-lis
1010
import { IconGrid } from "./IconGrid";
1111

1212
export const dynamic = "force-static";
13-
export const metadata: Metadata = _generateMetadataForStaticPage(
14-
"Icons Showcase",
15-
"",
16-
undefined,
17-
undefined,
18-
"/icons"
19-
);
13+
14+
export async function generateMetadata(): Promise<Metadata> {
15+
return await _generateMetadataForStaticPage("Icons Showcase", "", undefined, undefined, "/icons");
16+
}
2017

2118
const interFont = Inter({ subsets: ["latin"], variable: "--font-inter", preload: true, display: "swap" });
2219
const calFont = localFont({

apps/web/scripts/copy-app-store-static.js

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,45 @@
11
const fs = require("fs");
22
const path = require("path");
33
const glob = require("glob");
4+
const crypto = require("crypto");
45

56
const copyAppStoreStatic = () => {
67
// Get all static files from app-store packages
78
const staticFiles = glob.sync("../../packages/app-store/**/static/**/*", { nodir: true });
89

10+
// Object to store icon SVG hashes
11+
const SVG_HASHES = {};
12+
913
staticFiles.forEach((file) => {
1014
// Extract app name from path
1115
const appNameMatch = file.match(/app-store\/(.*?)\/static/);
1216
if (!appNameMatch) return;
1317

14-
const appName = appNameMatch[1];
18+
const appDirName = appNameMatch[1];
1519
const fileName = path.basename(file);
16-
1720
// Create destination directory if it doesn't exist
18-
const destDir = path.join(process.cwd(), "public", "app-store", appName);
21+
const destDir = path.join(process.cwd(), "public", "app-store", appDirName);
1922
if (!fs.existsSync(destDir)) {
2023
fs.mkdirSync(destDir, { recursive: true });
2124
}
2225

2326
// Copy file to destination (Turborepo caching handles change detection)
2427
const destPath = path.join(destDir, fileName);
2528
fs.copyFileSync(file, destPath);
29+
30+
// If it's an icon SVG file, compute hash
31+
if (fileName.includes("icon") && fileName.endsWith(".svg")) {
32+
const content = fs.readFileSync(file, "utf8");
33+
const hash = crypto.createHash("md5").update(content).digest("hex").slice(0, 8);
34+
SVG_HASHES[appDirName] = hash;
35+
}
36+
2637
console.log(`Copied ${file} to ${destPath}`);
2738
});
39+
40+
// Write SVG hashes to a JSON file
41+
const hashFilePath = path.join(process.cwd(), "public", "app-store", "svg-hashes.json");
42+
fs.writeFileSync(hashFilePath, JSON.stringify(SVG_HASHES, null, 2));
2843
};
2944

3045
// Run the copy function

0 commit comments

Comments
 (0)