Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 51 additions & 14 deletions packages/next/src/build/entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,10 @@ import {
isInternalComponent,
isNonRoutePagesPage,
} from '../lib/is-internal-component'
import { isMetadataRouteFile } from '../lib/metadata/is-metadata-route'
import {
isMetadataRouteFile,
isStaticMetadataFile,
} from '../lib/metadata/is-metadata-route'
import { RouteKind } from '../server/route-kind'
import { encodeToBase64 } from './webpack/loaders/utils'
import { normalizeCatchAllRoutes } from './normalize-catchall-routes'
Expand Down Expand Up @@ -94,8 +97,10 @@ export async function collectAppFiles(
appPaths: string[]
layoutPaths: string[]
defaultPaths: string[]
staticMetadataFiles: Map<string, string>
}> {
// Collect app pages, layouts, and default files in a single directory traversal
// Returns relative paths.
const allAppFiles = await recursiveReadDir(appDir, {
pathnameFilter: (absolutePath) =>
validFileMatcher.isAppRouterPage(absolutePath) ||
Expand All @@ -105,20 +110,52 @@ export async function collectAppFiles(
ignorePartFilter: (part) => part.startsWith('_'),
})

// Separate app pages, layouts, and defaults
const appPaths = allAppFiles.filter(
(absolutePath) =>
validFileMatcher.isAppRouterPage(absolutePath) ||
validFileMatcher.isRootNotFound(absolutePath)
)
const layoutPaths = allAppFiles.filter((absolutePath) =>
validFileMatcher.isAppLayoutPage(absolutePath)
)
const defaultPaths = allAppFiles.filter((absolutePath) =>
validFileMatcher.isAppDefaultPage(absolutePath)
)
// Separate app pages, layouts, defaults, and static metadata files
const appPaths = []
const layoutPaths = []
const defaultPaths = []
// Map of "requestPath" => "relativePath".
// "requestPath" will be used for the output path "{distDir}/static/metadata/{requestPath}" and
// its matcher. When the request comes in, the filesystem handler will look for the output path
// and serve the file if exists. "relativePath" will be used to copy the file to the output path.
const staticMetadataFiles = new Map<string, string>()

for (const relativePath of allAppFiles) {
if (validFileMatcher.isAppRouterPage(relativePath)) {
appPaths.push(relativePath)

// Static metadata files (favicon.ico, icon.png, etc.) need to be served
// directly from the filesystem. To enable this, we create a mapping from
// request paths to their actual file locations.
if (isStaticMetadataFile(relativePath)) {
let requestPath = relativePath
// Adds metadata file suffix and "/route" suffix
// "/@parallel/icon.png" -> "/@parallel/icon-<hash>.png/route"
requestPath = normalizeMetadataRoute(requestPath)
// Normalizes to app route request path
// "/@parallel/icon-<hash>.png/route" -> "/icon-<hash>.png"
requestPath = normalizeAppPath(requestPath)
// Converts underscore encoding
requestPath = requestPath.replace(/%5F/g, '_')
// Maps the final request path to the file location
staticMetadataFiles.set(requestPath, relativePath)
}
}

if (validFileMatcher.isRootNotFound(relativePath)) {
appPaths.push(relativePath)
}

if (validFileMatcher.isAppLayoutPage(relativePath)) {
layoutPaths.push(relativePath)
}

if (validFileMatcher.isAppDefaultPage(relativePath)) {
defaultPaths.push(relativePath)
}
}

return { appPaths, layoutPaths, defaultPaths }
return { appPaths, layoutPaths, defaultPaths, staticMetadataFiles }
}

/**
Expand Down
21 changes: 21 additions & 0 deletions packages/next/src/build/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1236,6 +1236,27 @@ export default async function build(
appPaths = result.appPaths
layoutPaths = result.layoutPaths
// Note: defaultPaths are not used in the build process, only for slot detection in generating route types

// Copy static metadata files for production filesystem serving.
// Not using a loader so that it works on all bundlers.
if (result.staticMetadataFiles.size > 0) {
await nextBuildSpan
.traceChild('copy-static-metadata-files')
.traceAsyncFn(async () => {
for (const [
requestPath,
relativePath,
] of result.staticMetadataFiles) {
const outPath = path.join(
distDir,
'static/metadata',
requestPath
)
await fs.mkdir(path.dirname(outPath), { recursive: true })
await fs.copyFile(path.join(appDir, relativePath), outPath)
}
})
}
}

mappedAppPages = await nextBuildSpan
Expand Down
14 changes: 14 additions & 0 deletions packages/next/src/server/lib/router-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import {
handleChromeDevtoolsWorkspaceRequest,
isChromeDevtoolsWorkspaceUrl,
} from './chrome-devtools-workspace'
import { isStaticMetadataFile } from '../../lib/metadata/is-metadata-route'

const debug = setupDebug('next:router-server:main')
const isNextFont = (pathname: string | null) =>
Expand Down Expand Up @@ -475,6 +476,19 @@ export async function initialize(opts: {
) {
if (opts.dev && !isNextFont(parsedUrl.pathname)) {
res.setHeader('Cache-Control', 'no-store, must-revalidate')
} else if (
isStaticMetadataFile(
// isStaticMetadataFile expects the app dir relative path, which is equivalent
// to a path from "{distDir}/static/metadata".
matchedOutput.itemPath.replace(
path.join(opts.dir, config.distDir, 'static/metadata'),
''
)
)
) {
// Static metadata files should be revalidated. This matches the behavior of
// the dynamic metadata files e.g., icon.tsx, sitemap.ts, etc.
res.setHeader('Cache-Control', 'public, max-age=0, must-revalidate')
} else {
res.setHeader(
'Cache-Control',
Expand Down
28 changes: 20 additions & 8 deletions packages/next/src/server/lib/router-utils/filesystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -507,14 +507,26 @@ export async function setupFsCheck(opts: {
}
}

if (opts.dev && isMetadataRouteFile(itemPath, [], false)) {
const fsPath = staticMetadataFiles.get(itemPath)
if (fsPath) {
return {
// "nextStaticFolder" sets Cache-Control "no-store" on dev.
type: 'nextStaticFolder',
fsPath,
itemPath: fsPath,
if (isMetadataRouteFile(itemPath, [], false)) {
if (opts.dev) {
const fsPath = staticMetadataFiles.get(itemPath)
if (fsPath) {
return {
// "nextStaticFolder" sets Cache-Control "no-store" on dev.
type: 'nextStaticFolder',
fsPath,
itemPath: fsPath,
}
}
} else {
const reqPath = path.join('/_next/static/metadata', itemPath)
if (nextStaticFolderItems.has(reqPath)) {
const fsPath = path.join(distDir, 'static/metadata', itemPath)
return {
type: 'nextStaticFolder',
fsPath,
itemPath: fsPath,
}
}
}
}
Expand Down
5 changes: 3 additions & 2 deletions test/e2e/app-dir/metadata/metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -711,8 +711,9 @@ describe('app dir - metadata', () => {
it('should support root dir robots.txt', async () => {
const res = await next.fetch('/robots.txt')
expect(res.headers.get('content-type')).toBe(
// In dev, sendStatic() is used to send static files, which adds MIME type.
isNextDev ? 'text/plain; charset=UTF-8' : 'text/plain'
// In deploy, it still uses route.js to serve metadata file.
// Otherwise, it uses sendStatic() to send static files, which adds MIME type.
isNextDeploy ? 'text/plain' : 'text/plain; charset=UTF-8'
)
expect(await res.text()).toContain('User-Agent: *\nDisallow:')
const invalidRobotsResponse = await next.fetch('/title/robots.txt')
Expand Down
4 changes: 4 additions & 0 deletions test/integration/app-dir-export/test/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ export const expectedWhenTrailingSlashTrue = [
? [expect.stringMatching(/_next\/static\/media\/favicon\.[0-9a-f]+\.ico/)]
: []),
expect.stringMatching(/_next\/static\/media\/test\.[0-9a-f]+\.png/),
'_next/static/metadata/favicon.ico',
'_next/static/metadata/robots.txt',
'_next/static/test-build-id/_buildManifest.js',
...(process.env.IS_TURBOPACK_TEST
? ['_next/static/test-build-id/_clientMiddlewareManifest.json']
Expand Down Expand Up @@ -69,6 +71,8 @@ const expectedWhenTrailingSlashFalse = [
? [expect.stringMatching(/_next\/static\/media\/favicon\.[0-9a-f]+\.ico/)]
: []),
expect.stringMatching(/_next\/static\/media\/test\.[0-9a-f]+\.png/),
'_next/static/metadata/favicon.ico',
'_next/static/metadata/robots.txt',
'_next/static/test-build-id/_buildManifest.js',
...(process.env.IS_TURBOPACK_TEST
? ['_next/static/test-build-id/_clientMiddlewareManifest.json']
Expand Down
Loading