Skip to content

Commit

Permalink
fix Next.js dynamic and static OG images (#6592)
Browse files Browse the repository at this point in the history
Fixed by adding the VERCEL_URL env var

---------

Co-authored-by: James Daniels <jamesdaniels@google.com>
  • Loading branch information
leoortizz and jamesdaniels committed Dec 8, 2023
1 parent e89059d commit 36c99a6
Show file tree
Hide file tree
Showing 7 changed files with 182 additions and 15 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Fix Next.js dynamic and static OG images. (#6592)
27 changes: 20 additions & 7 deletions src/frameworks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,17 +89,18 @@ const BUILD_MEMO = new Map<string[], Promise<BuildResult | void>>();
// Memoize the build based on both the dir and the environment variables
function memoizeBuild(
dir: string,
build: (dir: string, target: string) => Promise<BuildResult | void>,
build: Framework["build"],
deps: any[],
target: string
) {
target: string,
context: FrameworkContext
): ReturnType<Framework["build"]> {
const key = [dir, ...deps];
for (const existingKey of BUILD_MEMO.keys()) {
if (isDeepStrictEqual(existingKey, key)) {
return BUILD_MEMO.get(existingKey);
return BUILD_MEMO.get(existingKey) as ReturnType<Framework["build"]>;
}
}
const value = build(dir, target);
const value = build(dir, target, context);
BUILD_MEMO.set(key, value);
return value;
}
Expand Down Expand Up @@ -286,6 +287,12 @@ export async function prepareFrameworks(
purpose !== "deploy" &&
(await shouldUseDevModeHandle(frameworksBuildTarget, getProjectPath()));

const frameworkContext: FrameworkContext = {
projectId: project,
site: options.site,
hostingChannel: context?.hostingChannel,
};

let codegenFunctionsDirectory: Framework["ɵcodegenFunctionsDirectory"];
let baseUrl = "";
const rewrites = [];
Expand All @@ -309,7 +316,8 @@ export async function prepareFrameworks(
getProjectPath(),
build,
[firebaseDefaults, frameworksBuildTarget],
frameworksBuildTarget
frameworksBuildTarget,
frameworkContext
);
const { wantsBackend = false, trailingSlash, i18n = false }: BuildResult = buildResult || {};

Expand Down Expand Up @@ -397,7 +405,12 @@ export async function prepareFrameworks(
frameworksEntry = framework,
dotEnv = {},
rewriteSource,
} = await codegenFunctionsDirectory(getProjectPath(), functionsDist, frameworksBuildTarget);
} = await codegenFunctionsDirectory(
getProjectPath(),
functionsDist,
frameworksBuildTarget,
frameworkContext
);

const rewrite = {
source: rewriteSource || posix.join(baseUrl, "**"),
Expand Down
6 changes: 4 additions & 2 deletions src/frameworks/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,15 @@ export type FrameworksOptions = HostingOptions &
export type FrameworkContext = {
projectId?: string;
hostingChannel?: string;
site?: string;
};

export interface Framework {
supportedRange?: string;
discover: (dir: string) => Promise<Discovery | undefined>;
type: FrameworkType;
name: string;
build: (dir: string, target: string) => Promise<BuildResult | void>;
build: (dir: string, target: string, context?: FrameworkContext) => Promise<BuildResult | void>;
support: SupportLevel;
docsUrl?: string;
init?: (setup: any, config: any) => Promise<void>;
Expand All @@ -80,7 +81,8 @@ export interface Framework {
ɵcodegenFunctionsDirectory?: (
dir: string,
dest: string,
target: string
target: string,
context?: FrameworkContext
) => Promise<{
bootstrapScript?: string;
packageJson: any;
Expand Down
60 changes: 54 additions & 6 deletions src/frameworks/next/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,13 @@ import {
validateLocales,
getNodeModuleBin,
} from "../utils";
import { BuildResult, FrameworkType, SupportLevel } from "../interfaces";
import {
BuildResult,
Framework,
FrameworkContext,
FrameworkType,
SupportLevel,
} from "../interfaces";

import {
cleanEscapedChars,
Expand Down Expand Up @@ -67,7 +73,7 @@ import {
APP_PATHS_MANIFEST,
ESBUILD_VERSION,
} from "./constants";
import { getAllSiteDomains } from "../../hosting/api";
import { getAllSiteDomains, getDeploymentDomain } from "../../hosting/api";
import { logger } from "../../logger";

const DEFAULT_BUILD_SCRIPT = ["next build"];
Expand Down Expand Up @@ -101,7 +107,11 @@ export async function discover(dir: string) {
/**
* Build a next.js application.
*/
export async function build(dir: string): Promise<BuildResult> {
export async function build(
dir: string,
target: string,
context?: FrameworkContext
): Promise<BuildResult> {
await warnIfCustomBuildScript(dir, name, DEFAULT_BUILD_SCRIPT);

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

const env = { ...process.env };

if (context?.projectId && context?.site) {
const deploymentDomain = await getDeploymentDomain(
context.projectId,
context.site,
context.hostingChannel
);

if (deploymentDomain) {
// Add the deployment domain to VERCEL_URL env variable, which is
// required for dynamic OG images to work without manual configuration.
// See: https://nextjs.org/docs/app/api-reference/functions/generate-metadata#default-value
env["VERCEL_URL"] = deploymentDomain;
}
}

const cli = getNodeModuleBin("next", dir);

const nextBuild = new Promise((resolve, reject) => {
const buildProcess = spawn(cli, ["build"], { cwd: dir });
const buildProcess = spawn(cli, ["build"], { cwd: dir, env });
buildProcess.stdout?.on("data", (data) => logger.info(data.toString()));
buildProcess.stderr?.on("data", (data) => logger.info(data.toString()));
buildProcess.on("error", (err) => {
Expand Down Expand Up @@ -488,7 +515,12 @@ export async function ɵcodegenPublicDirectory(
/**
* Create a directory for SSR content.
*/
export async function ɵcodegenFunctionsDirectory(sourceDir: string, destDir: string) {
export async function ɵcodegenFunctionsDirectory(
sourceDir: string,
destDir: string,
target: string,
context?: FrameworkContext
): ReturnType<NonNullable<Framework["ɵcodegenFunctionsDirectory"]>> {
const { distDir } = await getConfig(sourceDir);
const packageJson = await readJSON(join(sourceDir, "package.json"));
// Bundle their next.config.js with esbuild via NPX, pinned version was having troubles on m1
Expand Down Expand Up @@ -558,9 +590,25 @@ export async function ɵcodegenFunctionsDirectory(sourceDir: string, destDir: st
packageJson.dependencies["sharp"] = SHARP_VERSION;
}

const dotEnv: Record<string, string> = {};
if (context?.projectId && context?.site) {
const deploymentDomain = await getDeploymentDomain(
context.projectId,
context.site,
context.hostingChannel
);

if (deploymentDomain) {
// Add the deployment domain to VERCEL_URL env variable, which is
// required for dynamic OG images to work without manual configuration.
// See: https://nextjs.org/docs/app/api-reference/functions/generate-metadata#default-value
dotEnv["VERCEL_URL"] = deploymentDomain;
}
}

await mkdirp(join(destDir, distDir));
await copy(join(sourceDir, distDir), join(destDir, distDir));
return { packageJson, frameworksEntry: "next.js" };
return { packageJson, frameworksEntry: "next.js", dotEnv };
}

/**
Expand Down
34 changes: 34 additions & 0 deletions src/hosting/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as operationPoller from "../operation-poller";
import { DEFAULT_DURATION } from "../hosting/expireUtils";
import { getAuthDomains, updateAuthDomains } from "../gcp/auth";
import * as proto from "../gcp/proto";
import { getHostnameFromUrl } from "../utils";

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

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

return Array.from(allSiteDomains);
}

/**
* Get the deployment domain.
* If hostingChannel is provided, get the channel url, otherwise get the
* default site url.
*/
export async function getDeploymentDomain(
projectId: string,
siteId: string,
hostingChannel?: string | undefined
): Promise<string | null> {
if (hostingChannel) {
const channel = await getChannel(projectId, siteId, hostingChannel);

return channel && getHostnameFromUrl(channel?.url);
}

const site = await getSite(projectId, siteId).catch((e: unknown) => {
// return null if the site doesn't exist
if (
e instanceof FirebaseError &&
e.original instanceof FirebaseError &&
e.original.status === 404
) {
return null;
}

throw e;
});

return site && getHostnameFromUrl(site?.defaultUrl);
}
58 changes: 58 additions & 0 deletions src/test/hosting/api.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -821,6 +821,64 @@ describe("hosting", () => {
expect(nock.isDone()).to.be.true;
});
});

describe("getDeploymentDomain", () => {
afterEach(nock.cleanAll);

it("should get the default site domain when hostingChannel is omitted", async () => {
const defaultDomain = EXPECTED_DOMAINS_RESPONSE[EXPECTED_DOMAINS_RESPONSE.length - 1];
const defaultUrl = `https://${defaultDomain}`;

nock(hostingApiOrigin)
.get(`/v1beta1/projects/${PROJECT_ID}/sites/${SITE}`)
.reply(200, { defaultUrl });

expect(await hostingApi.getDeploymentDomain(PROJECT_ID, SITE)).to.equal(defaultDomain);
});

it("should get the default site domain when hostingChannel is undefined", async () => {
const defaultDomain = EXPECTED_DOMAINS_RESPONSE[EXPECTED_DOMAINS_RESPONSE.length - 1];
const defaultUrl = `https://${defaultDomain}`;

nock(hostingApiOrigin)
.get(`/v1beta1/projects/${PROJECT_ID}/sites/${SITE}`)
.reply(200, { defaultUrl });

expect(await hostingApi.getDeploymentDomain(PROJECT_ID, SITE, undefined)).to.equal(
defaultDomain
);
});

it("should get the channel domain", async () => {
const channelId = "my-channel";
const channelDomain = `${PROJECT_ID}--${channelId}-123123.web.app`;
const channel = { url: `https://${channelDomain}` };

nock(hostingApiOrigin)
.get(`/v1beta1/projects/${PROJECT_ID}/sites/${SITE}/channels/${channelId}`)
.reply(200, channel);

expect(await hostingApi.getDeploymentDomain(PROJECT_ID, SITE, channelId)).to.equal(
channelDomain
);
});

it("should return null if channel not found", async () => {
const channelId = "my-channel";

nock(hostingApiOrigin)
.get(`/v1beta1/projects/${PROJECT_ID}/sites/${SITE}/channels/${channelId}`)
.reply(404, {});

expect(await hostingApi.getDeploymentDomain(PROJECT_ID, SITE, channelId)).to.be.null;
});

it("should return null if site not found", async () => {
nock(hostingApiOrigin).get(`/v1beta1/projects/${PROJECT_ID}/sites/${SITE}`).reply(404, {});

expect(await hostingApi.getDeploymentDomain(PROJECT_ID, SITE)).to.be.null;
});
});
});

describe("normalizeName", () => {
Expand Down
11 changes: 11 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -799,3 +799,14 @@ export async function openInBrowserPopup(
},
};
}

/**
* Get hostname from a given url or null if the url is invalid
*/
export function getHostnameFromUrl(url: string): string | null {
try {
return new URL(url).hostname;
} catch (e: unknown) {
return null;
}
}

0 comments on commit 36c99a6

Please sign in to comment.