diff --git a/CHANGELOG.md b/CHANGELOG.md index e69de29bb2d..a07e3051e65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -0,0 +1 @@ +- Fix Next.js dynamic and static OG images. (#6592) diff --git a/src/frameworks/index.ts b/src/frameworks/index.ts index 0bbbb4f1241..f5dc59b59d4 100644 --- a/src/frameworks/index.ts +++ b/src/frameworks/index.ts @@ -89,17 +89,18 @@ const BUILD_MEMO = new Map>(); // Memoize the build based on both the dir and the environment variables function memoizeBuild( dir: string, - build: (dir: string, target: string) => Promise, + build: Framework["build"], deps: any[], - target: string -) { + target: string, + context: FrameworkContext +): ReturnType { 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; } } - const value = build(dir, target); + const value = build(dir, target, context); BUILD_MEMO.set(key, value); return value; } @@ -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 = []; @@ -309,7 +316,8 @@ export async function prepareFrameworks( getProjectPath(), build, [firebaseDefaults, frameworksBuildTarget], - frameworksBuildTarget + frameworksBuildTarget, + frameworkContext ); const { wantsBackend = false, trailingSlash, i18n = false }: BuildResult = buildResult || {}; @@ -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, "**"), diff --git a/src/frameworks/interfaces.ts b/src/frameworks/interfaces.ts index f3825c0660a..0ea91951bc3 100644 --- a/src/frameworks/interfaces.ts +++ b/src/frameworks/interfaces.ts @@ -52,6 +52,7 @@ export type FrameworksOptions = HostingOptions & export type FrameworkContext = { projectId?: string; hostingChannel?: string; + site?: string; }; export interface Framework { @@ -59,7 +60,7 @@ export interface Framework { discover: (dir: string) => Promise; type: FrameworkType; name: string; - build: (dir: string, target: string) => Promise; + build: (dir: string, target: string, context?: FrameworkContext) => Promise; support: SupportLevel; docsUrl?: string; init?: (setup: any, config: any) => Promise; @@ -80,7 +81,8 @@ export interface Framework { ɵcodegenFunctionsDirectory?: ( dir: string, dest: string, - target: string + target: string, + context?: FrameworkContext ) => Promise<{ bootstrapScript?: string; packageJson: any; diff --git a/src/frameworks/next/index.ts b/src/frameworks/next/index.ts index 0368e6b28b6..2092bebafec 100644 --- a/src/frameworks/next/index.ts +++ b/src/frameworks/next/index.ts @@ -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, @@ -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"]; @@ -101,7 +107,11 @@ export async function discover(dir: string) { /** * Build a next.js application. */ -export async function build(dir: string): Promise { +export async function build( + dir: string, + target: string, + context?: FrameworkContext +): Promise { await warnIfCustomBuildScript(dir, name, DEFAULT_BUILD_SCRIPT); const reactVersion = getReactVersion(dir); @@ -110,10 +120,27 @@ export async function build(dir: string): Promise { 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) => { @@ -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> { 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 @@ -558,9 +590,25 @@ export async function ɵcodegenFunctionsDirectory(sourceDir: string, destDir: st packageJson.dependencies["sharp"] = SHARP_VERSION; } + const dotEnv: Record = {}; + 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 }; } /** diff --git a/src/hosting/api.ts b/src/hosting/api.ts index 2299552f868..c469e0af4fb 100644 --- a/src/hosting/api.ts +++ b/src/hosting/api.ts @@ -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 @@ -551,6 +552,7 @@ export async function getSite(project: string, site: string): Promise { 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; @@ -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 { + 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); +} diff --git a/src/test/hosting/api.spec.ts b/src/test/hosting/api.spec.ts index 8e0b34329d3..8a25ede2147 100644 --- a/src/test/hosting/api.spec.ts +++ b/src/test/hosting/api.spec.ts @@ -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", () => { diff --git a/src/utils.ts b/src/utils.ts index cf33dd3e9ac..93ea9e005f0 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -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; + } +}