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:
parent
355a4acc9e
commit
34c2a05da2
10 changed files with 110 additions and 43 deletions
|
@ -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) => {
|
||||
|
|
|
@ -17,6 +17,7 @@ function accumulateMetadata(metadataItems: MetadataItems) {
|
|||
return originAccumulateMetadata(fullMetadataItems, {
|
||||
pathname: '/test',
|
||||
trailingSlash: false,
|
||||
isStandaloneMode: false,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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' }])
|
||||
})
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -16,4 +16,5 @@ export type FieldResolverExtraArgs<
|
|||
export type MetadataContext = {
|
||||
pathname: string
|
||||
trailingSlash: boolean
|
||||
isStandaloneMode: boolean
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
2
test/e2e/app-dir/metadata-warnings/standalone.test.ts
Normal file
2
test/e2e/app-dir/metadata-warnings/standalone.test.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
process.env.TEST_STANDALONE = '1'
|
||||
require('./index.test')
|
Loading…
Reference in a new issue