Warn metadataBase missing in standalone mode or non vercel deployment (#66296)

### What

Change the metadataBase missing warning for all cases to only warn in
standalone mode or the non-vercel deployment.

### Why

In vercel deployments, previous concern was that you might not discover
you missed that metadataBase when you deploy. But now we have sth
fallback on production deployments. So we only need to warn in
non-vercel deployment.

Standalone is usually for self-hoist, we always warn users to set the
`metadataBase` to make sure the domain can be properly resolved.


[x-ref](https://vercel.slack.com/archives/C03S8ED1DKM/p1716926825853389?thread_ts=1716923373.484329&cid=C03S8ED1DKM)
This commit is contained in:
Jiachi Liu 2024-06-01 20:15:01 +02:00 committed by GitHub
parent 355a4acc9e
commit 34c2a05da2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 110 additions and 43 deletions

View file

@ -1,5 +1,8 @@
import type { ParsedUrlQuery } from 'querystring'
import type { GetDynamicParamFromSegment } from '../../server/app-render/app-render'
import type {
AppRenderContext,
GetDynamicParamFromSegment,
} from '../../server/app-render/app-render'
import type { LoaderTree } from '../../server/lib/app-dir-module'
import React from 'react'
@ -29,6 +32,18 @@ import {
createDefaultViewport,
} from './default-metadata'
import { isNotFoundError } from '../../client/components/not-found'
import type { MetadataContext } from './types/resolvers'
export function createMetadataContext(
urlPathname: string,
renderOpts: AppRenderContext['renderOpts']
): MetadataContext {
return {
pathname: urlPathname.split('?')[0],
trailingSlash: renderOpts.trailingSlash,
isStandaloneMode: renderOpts.nextConfigOutput === 'standalone',
}
}
// Use a promise to share the status of the metadata resolving,
// returning two components `MetadataTree` and `MetadataOutlet`
@ -38,18 +53,16 @@ import { isNotFoundError } from '../../client/components/not-found'
// and the error will be caught by the error boundary and trigger fallbacks.
export function createMetadataComponents({
tree,
pathname,
trailingSlash,
query,
metadataContext,
getDynamicParamFromSegment,
appUsingSizeAdjustment,
errorType,
createDynamicallyTrackedSearchParams,
}: {
tree: LoaderTree
pathname: string
trailingSlash: boolean
query: ParsedUrlQuery
metadataContext: MetadataContext
getDynamicParamFromSegment: GetDynamicParamFromSegment
appUsingSizeAdjustment: boolean
errorType?: 'not-found' | 'redirect'
@ -57,12 +70,6 @@ export function createMetadataComponents({
searchParams: ParsedUrlQuery
) => ParsedUrlQuery
}): [React.ComponentType, React.ComponentType] {
const metadataContext = {
// Make sure the pathname without query string
pathname: pathname.split('?')[0],
trailingSlash,
}
let resolve: (value: Error | undefined) => void | undefined
// Only use promise.resolve here to avoid unhandled rejections
const metadataErrorResolving = new Promise<Error | undefined>((res) => {

View file

@ -17,6 +17,7 @@ function accumulateMetadata(metadataItems: MetadataItems) {
return originAccumulateMetadata(fullMetadataItems, {
pathname: '/test',
trailingSlash: false,
isStandaloneMode: false,
})
}

View file

@ -120,6 +120,7 @@ function mergeStaticMetadata(
const resolvedTwitter = resolveTwitter(
{ ...target.twitter, images: twitter } as Twitter,
target.metadataBase,
metadataContext,
titleTemplates.twitter
)
target.twitter = resolvedTwitter
@ -192,6 +193,7 @@ function mergeMetadata({
target.twitter = resolveTwitter(
source.twitter,
metadataBase,
metadataContext,
titleTemplates.twitter
)
break
@ -566,7 +568,8 @@ function inheritFromMetadata(
const commonOgKeys = ['title', 'description', 'images'] as const
function postProcessMetadata(
metadata: ResolvedMetadata,
titleTemplates: TitleTemplates
titleTemplates: TitleTemplates,
metadataContext: MetadataContext
): ResolvedMetadata {
const { openGraph, twitter } = metadata
@ -599,6 +602,7 @@ function postProcessMetadata(
const partialTwitter = resolveTwitter(
autoFillProps,
metadata.metadataBase,
metadataContext,
titleTemplates.twitter
)
if (metadata.twitter) {
@ -778,7 +782,7 @@ export async function accumulateMetadata(
}
}
return postProcessMetadata(resolvedMetadata, titleTemplates)
return postProcessMetadata(resolvedMetadata, titleTemplates, metadataContext)
}
export async function accumulateViewport(

View file

@ -7,7 +7,7 @@ describe('resolveImages', () => {
it(`should resolve images`, () => {
const images = [image1, { url: image2, alt: 'Image2' }]
expect(resolveImages(images, null)).toEqual([
expect(resolveImages(images, null, false)).toEqual([
{ url: new URL(image1) },
{ url: new URL(image2), alt: 'Image2' },
])
@ -16,7 +16,7 @@ describe('resolveImages', () => {
it('should not mutate passed images', () => {
const images = [image1, { url: image2, alt: 'Image2' }]
resolveImages(images, null)
resolveImages(images, null, false)
expect(images).toEqual([image1, { url: image2, alt: 'Image2' }])
})

View file

@ -42,14 +42,20 @@ const OgTypeFields = {
function resolveAndValidateImage(
item: FlattenArray<OpenGraph['images'] | Twitter['images']>,
metadataBase: NonNullable<ResolvedMetadataBase>,
isMetadataBaseMissing: boolean
isMetadataBaseMissing: boolean,
isStandaloneMode: boolean
) {
if (!item) return undefined
const isItemUrl = isStringOrURL(item)
const inputUrl = isItemUrl ? item : item.url
if (!inputUrl) return undefined
validateResolvedImageUrl(inputUrl, metadataBase, isMetadataBaseMissing)
const isNonVercelDeployment =
!process.env.VERCEL && process.env.NODE_ENV === 'production'
// Validate url in self-host standalone mode or non-Vercel deployment
if (isStandaloneMode || isNonVercelDeployment) {
validateResolvedImageUrl(inputUrl, metadataBase, isMetadataBaseMissing)
}
return isItemUrl
? {
@ -64,15 +70,18 @@ function resolveAndValidateImage(
export function resolveImages(
images: Twitter['images'],
metadataBase: ResolvedMetadataBase
metadataBase: ResolvedMetadataBase,
isStandaloneMode: boolean
): NonNullable<ResolvedMetadata['twitter']>['images']
export function resolveImages(
images: OpenGraph['images'],
metadataBase: ResolvedMetadataBase
metadataBase: ResolvedMetadataBase,
isStandaloneMode: boolean
): NonNullable<ResolvedMetadata['openGraph']>['images']
export function resolveImages(
images: OpenGraph['images'] | Twitter['images'],
metadataBase: ResolvedMetadataBase
metadataBase: ResolvedMetadataBase,
isStandaloneMode: boolean
):
| NonNullable<ResolvedMetadata['twitter']>['images']
| NonNullable<ResolvedMetadata['openGraph']>['images'] {
@ -86,7 +95,8 @@ export function resolveImages(
const resolvedItem = resolveAndValidateImage(
item,
fallbackMetadataBase,
isMetadataBaseMissing
isMetadataBaseMissing,
isStandaloneMode
)
if (!resolvedItem) continue
@ -149,7 +159,11 @@ export const resolveOpenGraph: FieldResolverExtraArgs<
}
}
}
target.images = resolveImages(og.images, metadataBase)
target.images = resolveImages(
og.images,
metadataBase,
metadataContext.isStandaloneMode
)
}
const resolved = {
@ -179,8 +193,8 @@ const TwitterBasicInfoKeys = [
export const resolveTwitter: FieldResolverExtraArgs<
'twitter',
[ResolvedMetadataBase, string | null]
> = (twitter, metadataBase, titleTemplate) => {
[ResolvedMetadataBase, MetadataContext, string | null]
> = (twitter, metadataBase, metadataContext, titleTemplate) => {
if (!twitter) return null
let card = 'card' in twitter ? twitter.card : undefined
const resolved = {
@ -191,7 +205,11 @@ export const resolveTwitter: FieldResolverExtraArgs<
resolved[infoKey] = twitter[infoKey] || null
}
resolved.images = resolveImages(twitter.images, metadataBase)
resolved.images = resolveImages(
twitter.images,
metadataBase,
metadataContext.isStandaloneMode
)
card = card || (resolved.images?.length ? 'summary_large_image' : 'summary')
resolved.card = card

View file

@ -53,6 +53,7 @@ describe('resolveAbsoluteUrlWithPathname', () => {
const opts = {
trailingSlash: false,
pathname: '/',
isStandaloneMode: false,
}
const resolver = (url: string | URL) =>
resolveAbsoluteUrlWithPathname(url, metadataBase, opts)
@ -68,6 +69,7 @@ describe('resolveAbsoluteUrlWithPathname', () => {
const opts = {
trailingSlash: true,
pathname: '/',
isStandaloneMode: false,
}
const resolver = (url: string | URL) =>
resolveAbsoluteUrlWithPathname(url, metadataBase, opts)

View file

@ -16,4 +16,5 @@ export type FieldResolverExtraArgs<
export type MetadataContext = {
pathname: string
trailingSlash: boolean
isStandaloneMode: boolean
}

View file

@ -42,7 +42,10 @@ import {
NEXT_URL,
RSC_HEADER,
} from '../../client/components/app-router-headers'
import { createMetadataComponents } from '../../lib/metadata/metadata'
import {
createMetadataComponents,
createMetadataContext,
} from '../../lib/metadata/metadata'
import { RequestAsyncStorageWrapper } from '../async-storage/request-async-storage-wrapper'
import { StaticGenerationAsyncStorageWrapper } from '../async-storage/static-generation-async-storage-wrapper'
import { isNotFoundError } from '../../client/components/not-found'
@ -325,9 +328,8 @@ async function generateFlight(
if (!options?.skipFlight) {
const [MetadataTree, MetadataOutlet] = createMetadataComponents({
tree: loaderTree,
pathname: urlPathname,
trailingSlash: ctx.renderOpts.trailingSlash,
query,
metadataContext: createMetadataContext(urlPathname, ctx.renderOpts),
getDynamicParamFromSegment,
appUsingSizeAdjustment,
createDynamicallyTrackedSearchParams,
@ -450,9 +452,8 @@ async function ReactServerApp({ tree, ctx, asNotFound }: ReactServerAppProps) {
const [MetadataTree, MetadataOutlet] = createMetadataComponents({
tree,
errorType: asNotFound ? 'not-found' : undefined,
pathname: urlPathname,
trailingSlash: ctx.renderOpts.trailingSlash,
query,
metadataContext: createMetadataContext(urlPathname, ctx.renderOpts),
getDynamicParamFromSegment: getDynamicParamFromSegment,
appUsingSizeAdjustment: appUsingSizeAdjustment,
createDynamicallyTrackedSearchParams,
@ -538,8 +539,7 @@ async function ReactServerError({
const [MetadataTree] = createMetadataComponents({
tree,
pathname: urlPathname,
trailingSlash: ctx.renderOpts.trailingSlash,
metadataContext: createMetadataContext(urlPathname, ctx.renderOpts),
errorType,
query,
getDynamicParamFromSegment,

View file

@ -4,7 +4,7 @@ const METADATA_BASE_WARN_STRING =
'metadataBase property in metadata export is not set for resolving social open graph or twitter images,'
describe('app dir - metadata missing metadataBase', () => {
const { next, isNextDev, skipped } = nextTestSetup({
const { next, isNextDev, isNextDeploy, skipped } = nextTestSetup({
files: __dirname,
skipDeployment: true,
})
@ -13,6 +13,21 @@ describe('app dir - metadata missing metadataBase', () => {
return
}
if (process.env.TEST_STANDALONE === '1') {
beforeAll(async () => {
await next.stop()
await next.patchFile(
'next.config.js',
`
module.exports = {
output: 'standalone',
}
`
)
await next.start()
})
}
// If it's start mode, we get the whole logs since they're from build process.
// If it's development mode, we get the logs after request
function getCliOutput(logStartPosition: number) {
@ -28,16 +43,33 @@ describe('app dir - metadata missing metadataBase', () => {
})
}
it('should fallback to localhost if metadataBase is missing for absolute urls resolving', async () => {
const logStartPosition = next.cliOutput.length
await next.fetch('/og-image-convention')
const output = getCliOutput(logStartPosition)
expect(output).toInclude(METADATA_BASE_WARN_STRING)
expect(output).toMatch(/using "http:\/\/localhost:\d+/)
expect(output).toInclude(
'. See https://nextjs.org/docs/app/api-reference/functions/generate-metadata#metadatabase'
)
})
if (process.env.TEST_STANDALONE === '1') {
// Standalone mode should always show the warning
it('should fallback to localhost if metadataBase is missing for absolute urls resolving', async () => {
const logStartPosition = next.cliOutput.length
await next.fetch('/og-image-convention')
const output = getCliOutput(logStartPosition)
expect(output).toInclude(METADATA_BASE_WARN_STRING)
expect(output).toMatch(/using "http:\/\/localhost:\d+/)
expect(output).toInclude(
'. See https://nextjs.org/docs/app/api-reference/functions/generate-metadata#metadatabase'
)
})
} else {
// Default output mode
it('should show warning in vercel deployment output in default build output mode', async () => {
const logStartPosition = next.cliOutput.length
await next.fetch('/og-image-convention')
const output = getCliOutput(logStartPosition)
if (isNextDeploy || isNextDev) {
expect(output).not.toInclude(METADATA_BASE_WARN_STRING)
} else {
expect(output).toInclude(METADATA_BASE_WARN_STRING)
}
})
}
it('should warn for unsupported metadata properties', async () => {
const logStartPosition = next.cliOutput.length

View file

@ -0,0 +1,2 @@
process.env.TEST_STANDALONE = '1'
require('./index.test')