Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
5bc55e4
use etags and 1 yera client caching
hbjORbj Sep 1, 2025
d7f1b3c
add version param
hbjORbj Sep 1, 2025
4818984
fix
hbjORbj Sep 1, 2025
5723494
fix
hbjORbj Sep 1, 2025
900901f
safer
hbjORbj Sep 1, 2025
882fe57
update script
hbjORbj Sep 1, 2025
fc497ed
update
hbjORbj Sep 1, 2025
f944934
final
hbjORbj Sep 1, 2025
b603523
fallback
hbjORbj Sep 1, 2025
3ffcc06
update
hbjORbj Sep 1, 2025
7c295b6
update script
hbjORbj Sep 1, 2025
269978d
refactor
hbjORbj Sep 1, 2025
c6ef44e
clearer variable naming
hbjORbj Sep 1, 2025
c1827ec
fix name
hbjORbj Sep 1, 2025
3358d0c
remove log
hbjORbj Sep 1, 2025
525991d
remove dynamic import
hbjORbj Sep 1, 2025
41ad619
fix test
hbjORbj Sep 2, 2025
3262520
Merge remote-tracking branch 'origin/main' into perf/og-image-caching-v2
hbjORbj Sep 2, 2025
c79cc09
Merge branch 'main' into perf/og-image-caching-v2
keithwillcode Sep 2, 2025
54264aa
Merge branch 'main' into perf/og-image-caching-v2
keithwillcode Sep 2, 2025
cce6cb3
Merge branch 'main' into perf/og-image-caching-v2
hbjORbj Sep 2, 2025
d83024a
wip
hbjORbj Sep 4, 2025
5984bd1
Merge branch 'main' into perf/og-image-caching-v2
hbjORbj Sep 5, 2025
d6361e1
Merge remote-tracking branch 'origin/main' into perf/og-image-caching-v2
hbjORbj Sep 12, 2025
ea3b95f
Merge remote-tracking branch 'origin/main' into perf/og-image-caching-v2
hbjORbj Oct 9, 2025
050e89c
Merge branch 'main' into perf/og-image-caching-v2
hbjORbj Oct 10, 2025
f65efff
clean up
hbjORbj Oct 10, 2025
3084d3f
Merge branch 'main' into perf/og-image-caching-v2
hbjORbj Oct 17, 2025
82db1b9
Merge remote-tracking branch 'origin/main' into perf/og-image-caching-v2
hbjORbj Oct 17, 2025
85b96dc
fix unit test
hbjORbj Oct 17, 2025
b153568
fix
hbjORbj Oct 17, 2025
eaeaafb
Merge remote-tracking branch 'origin/main' into perf/og-image-caching-v2
hbjORbj Oct 19, 2025
7d89703
fix
hbjORbj Oct 19, 2025
318841b
add FONTS
hbjORbj Oct 19, 2025
0d495e1
update api route
hbjORbj Oct 19, 2025
f5a4536
clean up
hbjORbj Oct 19, 2025
74feba7
wip
hbjORbj Oct 19, 2025
d19d7f1
update
hbjORbj Oct 19, 2025
58ef2c8
refactor
hbjORbj Oct 21, 2025
d104f32
Merge remote-tracking branch 'origin/main' into perf/og-image-caching-v2
hbjORbj Oct 21, 2025
d1c7ef2
Merge remote-tracking branch 'origin/main' into perf/og-image-caching-v2
hbjORbj Oct 25, 2025
088ae6b
fix test
hbjORbj Oct 25, 2025
6853b59
fix
hbjORbj Oct 25, 2025
bdf6728
refactor
hbjORbj Oct 25, 2025
cb19243
refactor
hbjORbj Oct 25, 2025
3e58816
refactor
hbjORbj Oct 26, 2025
d03f4d1
refactor
hbjORbj Oct 26, 2025
576ec64
refactor
hbjORbj Oct 26, 2025
9cfb7d1
finalize
hbjORbj Oct 27, 2025
9fa732c
Merge remote-tracking branch 'origin/main' into perf/og-image-caching-v2
hbjORbj Oct 27, 2025
6d8ad38
rename function
hbjORbj Oct 27, 2025
1485245
Merge branch 'main' into perf/og-image-caching-v2
hbjORbj Oct 27, 2025
5093f64
Merge branch 'main' into perf/og-image-caching-v2
hbjORbj Oct 27, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions apps/web/app/(use-page-wrapper)/apps/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

props.data.dirName: zoomvideo (as in "packages/app-store/zoomvideo/....")
props.data.slug: zoom


return await generateAppMetadata(
{ slug: logo, name, description },
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previously, slug was containing a relative path to the logo image, e.g., /app-store/dub/icon-dark.svg. This is wrong. I now actually pass the "slug" and now pass this relative path as logoUrl

{ slug: appStoreDirSlug ?? appSlug, logoUrl: logo, name, description },
() => name,
() => description,
undefined,
undefined,
`/apps/${p.data.slug}`
`/apps/${appSlug}`
);
};

Expand Down
14 changes: 7 additions & 7 deletions apps/web/app/_utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,10 @@ export const _generateMetadata = async (
);
const image =
SEO_IMG_OGIMG +
constructGenericImage({
(await constructGenericImage({
title: metadata.title,
description: metadata.description,
});
}));

return {
...metadata,
Expand All @@ -80,7 +80,7 @@ export const _generateMetadata = async (
};
};

export const _generateMetadataForStaticPage = (
export const _generateMetadataForStaticPage = async (
title: string,
description: string,
hideBranding?: boolean,
Expand Down Expand Up @@ -108,10 +108,10 @@ export const _generateMetadataForStaticPage = (
};
const image =
SEO_IMG_OGIMG +
constructGenericImage({
(await constructGenericImage({
title: metadata.title,
description: metadata.description,
});
}));

return {
...metadata,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
54 changes: 45 additions & 9 deletions apps/web/app/api/social/og/image/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => ({
Expand All @@ -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(import("@calcom/lib/constants"), async (importOriginal) => {
return await importOriginal();
});

vi.mock("@calcom/lib/constants", () => ({
WEBAPP_URL: "http://localhost:3000",
vi.mock("@calcom/web/public/app-store/svg-hashes.json", () => ({
default: {
huddle01video: "81a0653b",
zoomvideo: "d1c78abf",
},
}));

global.fetch = vi.fn();
Expand Down Expand Up @@ -82,15 +89,15 @@ 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 () => {
const request = createNextRequest("http://example.com/api/social/og/image?type=invalid");
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");
});
});

Expand All @@ -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);
});
});
});
36 changes: 28 additions & 8 deletions apps/web/app/api/social/og/image/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -22,6 +22,7 @@ const appSchema = z.object({
name: z.string(),
description: z.string(),
slug: z.string(),
logoUrl: z.string(),
});

const genericSchema = z.object({
Expand Down Expand Up @@ -74,6 +75,7 @@ async function handler(req: NextRequest) {
imageType,
});

const etag = await getOGImageVersion("meeting");
const img = new ImageResponse(
(
<Meeting
Expand All @@ -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) {
Expand All @@ -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;
Comment on lines +127 to +129
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

svg-hashes.json looks like:

{
  "zoomvideo": "d1c78abf",
  "zohocrm": "102d69df",
  "zohocalendar": "a9a369fe",
  "zoho-bigin": "d3ae97fd",
  "zapier": "9a1570dd",
  "wordpress": "d572db7e",
  "wipemycalother": "e4bb0e21",
  "whereby": "65fd921f",
  "whatsapp": "4025a2c2",
  "weather_in_your_calendar": "4111fee5",
  "vital": "86b4bf1e",
  "vimcal": "8001675b",
  "umami": "0593be46",
  "twipla": "86191ba1",
  "templates/link-as-an-app": "506ff3da",
  "templates/general-app-settings": "506ff3da",
  "templates/event-type-location-video-static": "506ff3da",
  "templates/event-type-app-card": "506ff3da",
  "templates/booking-pages-tag": "506ff3da",
  "templates/basic": "506ff3da",
  "telli": "202f4d2a",
  "telegram": "73f9a0ed",
  "tandemvideo": "7eb7508a",
  "synthflow": "283524ad",
  "sylapsvideo": "3e48b068",
  "stripepayment": "11b0b1ee",
  "skype": "4176a41c",
  "sirius_video": "7c49e446",
  "signal": "0736faad",
  "salesroom": "b5a77692",
  "routing-forms": "d6cfc1dd",
  "roam": "4c6094e4",
  "riverside": "61e15a9d",
  "retell-ai": "f90d2781",
  "raycast": "d94a2a01",
  "qr_code": "9efd6cef",
  "posthog": "8681a845",
  "plausible": "91049a3a",
  "pipedrive-crm": "673e0931",
  "pipedream": "5600de90",
  "ping": "5bb01833",
  "paypal": "a540c421",
  "office365video": "97d4df35",
  "office365calendar": "ac5dd392",
  "nextcloudtalk": "d0266d7a",
  "n8n": "c92f3b72",
  "monobot": "6d601f42",
  "mock-payment-app": "506ff3da",
  "mirotalk": "f5298670",
  "metapixel": "fa32781d",
  "matomo": "7c2e329e",
  "make": "ca519871",
  "linear": "f78f9869",
  "lindy": "c7ab2989",
  "larkcalendar": "f93540ea",
  "jitsivideo": "79fdec8b",
  "jelly": "040ad0e1",
  "intercom": "fd949dc0",
  "insihts": "aa7e93d7",
  "ics-feedcalendar": "44c4adaa",
  "huddle01video": "81a0653b",
  "hubspot": "a6e01fff",
  "horizon-workrooms": "fa32781d",
  "hitpay": "9f0a5120",
  "gtm": "65847f31",
  "greetmate-ai": "025e82b7",
  "granola": "2829eb38",
  "googlevideo": "e4bb0e21",
  "googlecalendar": "062af390",
  "giphy": "c67e5a9b",
  "ga4": "9822cbf6",
  "feishucalendar": "f93540ea",
  "fathom": "9fde6e4c",
  "facetime": "09f45b11",
  "exchangecalendar": "a63ab3e4",
  "exchange2016calendar": "a63ab3e4",
  "exchange2013calendar": "a63ab3e4",
  "elevenlabs": "9ae79100",
  "element-call": "8c79e11f",
  "eightxeight": "3a97ea08",
  "dub": "db4e9834",
  "discord": "147f41c9",
  "dialpad": "aef13faa",
  "demodesk": "fd72da43",
  "deel": "5e1041f9",
  "dailyvideo": "9567de52",
  "closecom": "45302531",
  "chatbase": "332dee04",
  "campfire": "584fd592",
  "caldavcalendar": "70757035",
  "btcpayserver": "02235e9e",
  "bolna": "6fd7aa5f",
  "basecamp3": "de61c3ec",
  "baa-for-hipaa": "735c46eb",
  "autocheckin": "94ffa8f2",
  "attio": "0bc3964f",
  "applecalendar": "1fde27dd",
  "amie": "f9c089a1",
  "alby": "40854e92"
}

The key for Zoom, for example, is zoomvideo, not zoom


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) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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 });
}
}
Expand Down
4 changes: 4 additions & 0 deletions apps/web/app/api/social/og/image/types.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
declare module "@calcom/web/public/app-store/svg-hashes.json" {
const value: Record<string, string>;
export default value;
}
11 changes: 4 additions & 7 deletions apps/web/app/icons/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
21 changes: 18 additions & 3 deletions apps/web/scripts/copy-app-store-static.js
Original file line number Diff line number Diff line change
@@ -1,30 +1,45 @@
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];
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

more precise name. For Zoom, we could think appName here is zoom, but in fact, it is zoomvideo here because the directory name in packages/app-store/zoomvideo/... is zoomvideo

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

// 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
Expand Down
Loading
Loading