Skip to content

Commit 36c99a6

Browse files
fix Next.js dynamic and static OG images (#6592)
Fixed by adding the VERCEL_URL env var --------- Co-authored-by: James Daniels <jamesdaniels@google.com>
1 parent e89059d commit 36c99a6

File tree

7 files changed

+182
-15
lines changed

7 files changed

+182
-15
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- Fix Next.js dynamic and static OG images. (#6592)

src/frameworks/index.ts

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -89,17 +89,18 @@ const BUILD_MEMO = new Map<string[], Promise<BuildResult | void>>();
8989
// Memoize the build based on both the dir and the environment variables
9090
function memoizeBuild(
9191
dir: string,
92-
build: (dir: string, target: string) => Promise<BuildResult | void>,
92+
build: Framework["build"],
9393
deps: any[],
94-
target: string
95-
) {
94+
target: string,
95+
context: FrameworkContext
96+
): ReturnType<Framework["build"]> {
9697
const key = [dir, ...deps];
9798
for (const existingKey of BUILD_MEMO.keys()) {
9899
if (isDeepStrictEqual(existingKey, key)) {
99-
return BUILD_MEMO.get(existingKey);
100+
return BUILD_MEMO.get(existingKey) as ReturnType<Framework["build"]>;
100101
}
101102
}
102-
const value = build(dir, target);
103+
const value = build(dir, target, context);
103104
BUILD_MEMO.set(key, value);
104105
return value;
105106
}
@@ -286,6 +287,12 @@ export async function prepareFrameworks(
286287
purpose !== "deploy" &&
287288
(await shouldUseDevModeHandle(frameworksBuildTarget, getProjectPath()));
288289

290+
const frameworkContext: FrameworkContext = {
291+
projectId: project,
292+
site: options.site,
293+
hostingChannel: context?.hostingChannel,
294+
};
295+
289296
let codegenFunctionsDirectory: Framework["ɵcodegenFunctionsDirectory"];
290297
let baseUrl = "";
291298
const rewrites = [];
@@ -309,7 +316,8 @@ export async function prepareFrameworks(
309316
getProjectPath(),
310317
build,
311318
[firebaseDefaults, frameworksBuildTarget],
312-
frameworksBuildTarget
319+
frameworksBuildTarget,
320+
frameworkContext
313321
);
314322
const { wantsBackend = false, trailingSlash, i18n = false }: BuildResult = buildResult || {};
315323

@@ -397,7 +405,12 @@ export async function prepareFrameworks(
397405
frameworksEntry = framework,
398406
dotEnv = {},
399407
rewriteSource,
400-
} = await codegenFunctionsDirectory(getProjectPath(), functionsDist, frameworksBuildTarget);
408+
} = await codegenFunctionsDirectory(
409+
getProjectPath(),
410+
functionsDist,
411+
frameworksBuildTarget,
412+
frameworkContext
413+
);
401414

402415
const rewrite = {
403416
source: rewriteSource || posix.join(baseUrl, "**"),

src/frameworks/interfaces.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,14 +52,15 @@ export type FrameworksOptions = HostingOptions &
5252
export type FrameworkContext = {
5353
projectId?: string;
5454
hostingChannel?: string;
55+
site?: string;
5556
};
5657

5758
export interface Framework {
5859
supportedRange?: string;
5960
discover: (dir: string) => Promise<Discovery | undefined>;
6061
type: FrameworkType;
6162
name: string;
62-
build: (dir: string, target: string) => Promise<BuildResult | void>;
63+
build: (dir: string, target: string, context?: FrameworkContext) => Promise<BuildResult | void>;
6364
support: SupportLevel;
6465
docsUrl?: string;
6566
init?: (setup: any, config: any) => Promise<void>;
@@ -80,7 +81,8 @@ export interface Framework {
8081
ɵcodegenFunctionsDirectory?: (
8182
dir: string,
8283
dest: string,
83-
target: string
84+
target: string,
85+
context?: FrameworkContext
8486
) => Promise<{
8587
bootstrapScript?: string;
8688
packageJson: any;

src/frameworks/next/index.ts

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,13 @@ import {
3030
validateLocales,
3131
getNodeModuleBin,
3232
} from "../utils";
33-
import { BuildResult, FrameworkType, SupportLevel } from "../interfaces";
33+
import {
34+
BuildResult,
35+
Framework,
36+
FrameworkContext,
37+
FrameworkType,
38+
SupportLevel,
39+
} from "../interfaces";
3440

3541
import {
3642
cleanEscapedChars,
@@ -67,7 +73,7 @@ import {
6773
APP_PATHS_MANIFEST,
6874
ESBUILD_VERSION,
6975
} from "./constants";
70-
import { getAllSiteDomains } from "../../hosting/api";
76+
import { getAllSiteDomains, getDeploymentDomain } from "../../hosting/api";
7177
import { logger } from "../../logger";
7278

7379
const DEFAULT_BUILD_SCRIPT = ["next build"];
@@ -101,7 +107,11 @@ export async function discover(dir: string) {
101107
/**
102108
* Build a next.js application.
103109
*/
104-
export async function build(dir: string): Promise<BuildResult> {
110+
export async function build(
111+
dir: string,
112+
target: string,
113+
context?: FrameworkContext
114+
): Promise<BuildResult> {
105115
await warnIfCustomBuildScript(dir, name, DEFAULT_BUILD_SCRIPT);
106116

107117
const reactVersion = getReactVersion(dir);
@@ -110,10 +120,27 @@ export async function build(dir: string): Promise<BuildResult> {
110120
process.env.__NEXT_REACT_ROOT = "true";
111121
}
112122

123+
const env = { ...process.env };
124+
125+
if (context?.projectId && context?.site) {
126+
const deploymentDomain = await getDeploymentDomain(
127+
context.projectId,
128+
context.site,
129+
context.hostingChannel
130+
);
131+
132+
if (deploymentDomain) {
133+
// Add the deployment domain to VERCEL_URL env variable, which is
134+
// required for dynamic OG images to work without manual configuration.
135+
// See: https://nextjs.org/docs/app/api-reference/functions/generate-metadata#default-value
136+
env["VERCEL_URL"] = deploymentDomain;
137+
}
138+
}
139+
113140
const cli = getNodeModuleBin("next", dir);
114141

115142
const nextBuild = new Promise((resolve, reject) => {
116-
const buildProcess = spawn(cli, ["build"], { cwd: dir });
143+
const buildProcess = spawn(cli, ["build"], { cwd: dir, env });
117144
buildProcess.stdout?.on("data", (data) => logger.info(data.toString()));
118145
buildProcess.stderr?.on("data", (data) => logger.info(data.toString()));
119146
buildProcess.on("error", (err) => {
@@ -488,7 +515,12 @@ export async function ɵcodegenPublicDirectory(
488515
/**
489516
* Create a directory for SSR content.
490517
*/
491-
export async function ɵcodegenFunctionsDirectory(sourceDir: string, destDir: string) {
518+
export async function ɵcodegenFunctionsDirectory(
519+
sourceDir: string,
520+
destDir: string,
521+
target: string,
522+
context?: FrameworkContext
523+
): ReturnType<NonNullable<Framework["ɵcodegenFunctionsDirectory"]>> {
492524
const { distDir } = await getConfig(sourceDir);
493525
const packageJson = await readJSON(join(sourceDir, "package.json"));
494526
// Bundle their next.config.js with esbuild via NPX, pinned version was having troubles on m1
@@ -558,9 +590,25 @@ export async function ɵcodegenFunctionsDirectory(sourceDir: string, destDir: st
558590
packageJson.dependencies["sharp"] = SHARP_VERSION;
559591
}
560592

593+
const dotEnv: Record<string, string> = {};
594+
if (context?.projectId && context?.site) {
595+
const deploymentDomain = await getDeploymentDomain(
596+
context.projectId,
597+
context.site,
598+
context.hostingChannel
599+
);
600+
601+
if (deploymentDomain) {
602+
// Add the deployment domain to VERCEL_URL env variable, which is
603+
// required for dynamic OG images to work without manual configuration.
604+
// See: https://nextjs.org/docs/app/api-reference/functions/generate-metadata#default-value
605+
dotEnv["VERCEL_URL"] = deploymentDomain;
606+
}
607+
}
608+
561609
await mkdirp(join(destDir, distDir));
562610
await copy(join(sourceDir, distDir), join(destDir, distDir));
563-
return { packageJson, frameworksEntry: "next.js" };
611+
return { packageJson, frameworksEntry: "next.js", dotEnv };
564612
}
565613

566614
/**

src/hosting/api.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import * as operationPoller from "../operation-poller";
55
import { DEFAULT_DURATION } from "../hosting/expireUtils";
66
import { getAuthDomains, updateAuthDomains } from "../gcp/auth";
77
import * as proto from "../gcp/proto";
8+
import { getHostnameFromUrl } from "../utils";
89

910
const ONE_WEEK_MS = 604800000; // 7 * 24 * 60 * 60 * 1000
1011

@@ -551,6 +552,7 @@ export async function getSite(project: string, site: string): Promise<Site> {
551552
if (e instanceof FirebaseError && e.status === 404) {
552553
throw new FirebaseError(`could not find site "${site}" for project "${project}"`, {
553554
original: e,
555+
status: e.status,
554556
});
555557
}
556558
throw e;
@@ -751,3 +753,35 @@ export async function getAllSiteDomains(projectId: string, siteId: string): Prom
751753

752754
return Array.from(allSiteDomains);
753755
}
756+
757+
/**
758+
* Get the deployment domain.
759+
* If hostingChannel is provided, get the channel url, otherwise get the
760+
* default site url.
761+
*/
762+
export async function getDeploymentDomain(
763+
projectId: string,
764+
siteId: string,
765+
hostingChannel?: string | undefined
766+
): Promise<string | null> {
767+
if (hostingChannel) {
768+
const channel = await getChannel(projectId, siteId, hostingChannel);
769+
770+
return channel && getHostnameFromUrl(channel?.url);
771+
}
772+
773+
const site = await getSite(projectId, siteId).catch((e: unknown) => {
774+
// return null if the site doesn't exist
775+
if (
776+
e instanceof FirebaseError &&
777+
e.original instanceof FirebaseError &&
778+
e.original.status === 404
779+
) {
780+
return null;
781+
}
782+
783+
throw e;
784+
});
785+
786+
return site && getHostnameFromUrl(site?.defaultUrl);
787+
}

src/test/hosting/api.spec.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -821,6 +821,64 @@ describe("hosting", () => {
821821
expect(nock.isDone()).to.be.true;
822822
});
823823
});
824+
825+
describe("getDeploymentDomain", () => {
826+
afterEach(nock.cleanAll);
827+
828+
it("should get the default site domain when hostingChannel is omitted", async () => {
829+
const defaultDomain = EXPECTED_DOMAINS_RESPONSE[EXPECTED_DOMAINS_RESPONSE.length - 1];
830+
const defaultUrl = `https://${defaultDomain}`;
831+
832+
nock(hostingApiOrigin)
833+
.get(`/v1beta1/projects/${PROJECT_ID}/sites/${SITE}`)
834+
.reply(200, { defaultUrl });
835+
836+
expect(await hostingApi.getDeploymentDomain(PROJECT_ID, SITE)).to.equal(defaultDomain);
837+
});
838+
839+
it("should get the default site domain when hostingChannel is undefined", async () => {
840+
const defaultDomain = EXPECTED_DOMAINS_RESPONSE[EXPECTED_DOMAINS_RESPONSE.length - 1];
841+
const defaultUrl = `https://${defaultDomain}`;
842+
843+
nock(hostingApiOrigin)
844+
.get(`/v1beta1/projects/${PROJECT_ID}/sites/${SITE}`)
845+
.reply(200, { defaultUrl });
846+
847+
expect(await hostingApi.getDeploymentDomain(PROJECT_ID, SITE, undefined)).to.equal(
848+
defaultDomain
849+
);
850+
});
851+
852+
it("should get the channel domain", async () => {
853+
const channelId = "my-channel";
854+
const channelDomain = `${PROJECT_ID}--${channelId}-123123.web.app`;
855+
const channel = { url: `https://${channelDomain}` };
856+
857+
nock(hostingApiOrigin)
858+
.get(`/v1beta1/projects/${PROJECT_ID}/sites/${SITE}/channels/${channelId}`)
859+
.reply(200, channel);
860+
861+
expect(await hostingApi.getDeploymentDomain(PROJECT_ID, SITE, channelId)).to.equal(
862+
channelDomain
863+
);
864+
});
865+
866+
it("should return null if channel not found", async () => {
867+
const channelId = "my-channel";
868+
869+
nock(hostingApiOrigin)
870+
.get(`/v1beta1/projects/${PROJECT_ID}/sites/${SITE}/channels/${channelId}`)
871+
.reply(404, {});
872+
873+
expect(await hostingApi.getDeploymentDomain(PROJECT_ID, SITE, channelId)).to.be.null;
874+
});
875+
876+
it("should return null if site not found", async () => {
877+
nock(hostingApiOrigin).get(`/v1beta1/projects/${PROJECT_ID}/sites/${SITE}`).reply(404, {});
878+
879+
expect(await hostingApi.getDeploymentDomain(PROJECT_ID, SITE)).to.be.null;
880+
});
881+
});
824882
});
825883

826884
describe("normalizeName", () => {

src/utils.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -799,3 +799,14 @@ export async function openInBrowserPopup(
799799
},
800800
};
801801
}
802+
803+
/**
804+
* Get hostname from a given url or null if the url is invalid
805+
*/
806+
export function getHostnameFromUrl(url: string): string | null {
807+
try {
808+
return new URL(url).hostname;
809+
} catch (e: unknown) {
810+
return null;
811+
}
812+
}

0 commit comments

Comments
 (0)