Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: added more strict app segment config parsing #70479

Merged
merged 2 commits into from
Oct 1, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions packages/next/src/build/analysis/get-page-static-info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,9 +437,11 @@ function warnAboutExperimentalEdge(apiRoute: string | null) {
) {
return
}

if (apiRouteWarnings.has(apiRoute)) {
return
}

Log.warn(
apiRoute
? `${apiRoute} provided runtime 'experimental-edge'. It can be updated to 'edge' instead.`
Expand Down
125 changes: 125 additions & 0 deletions packages/next/src/build/app-segments/app-segment-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { z } from 'next/dist/compiled/zod'

/**
* The schema for configuration for a page.
*
* @internal
*/
export const AppSegmentConfigSchema = z.object({
/**
* The number of seconds to revalidate the page or false to disable revalidation.
*/
revalidate: z
.union([z.number().int().nonnegative(), z.literal(false)])
.optional(),

/**
* Whether the page supports dynamic parameters.
*/
dynamicParams: z.boolean().optional(),

/**
* The dynamic behavior of the page.
*/
dynamic: z
.enum(['auto', 'error', 'force-static', 'force-dynamic'])
.optional(),

/**
* The caching behavior of the page.
*/
fetchCache: z
.enum([
'auto',
'default-cache',
'only-cache',
'force-cache',
'force-no-store',
'default-no-store',
'only-no-store',
])
.optional(),

/**
* The preferred region for the page.
*/
preferredRegion: z.union([z.string(), z.array(z.string())]).optional(),

/**
* Whether the page supports partial prerendering. When true, the page will be
* served using partial prerendering. This setting will only take affect if
* it's enabled via the `experimental.ppr = "incremental"` option.
*/
experimental_ppr: z.boolean().optional(),

/**
* The runtime to use for the page.
*/
runtime: z.enum(['edge', 'nodejs']).optional(),

/**
* The maximum duration for the page in seconds.
*/
maxDuration: z.number().int().nonnegative().optional(),
})

/**
* The configuration for a page.
*/
export type AppSegmentConfig = {
/**
* The revalidation period for the page in seconds, or false to disable ISR.
*/
revalidate?: number | false

/**
* Whether the page supports dynamic parameters.
*/
dynamicParams?: boolean

/**
* The dynamic behavior of the page.
*/
dynamic?: 'auto' | 'error' | 'force-static' | 'force-dynamic'

/**
* The caching behavior of the page.
*/
fetchCache?:
| 'auto'
| 'default-cache'
| 'default-no-store'
| 'force-cache'
| 'force-no-store'
| 'only-cache'
| 'only-no-store'

/**
* The preferred region for the page.
*/
preferredRegion?: string | string[]

/**
* Whether the page supports partial prerendering. When true, the page will be
* served using partial prerendering. This setting will only take affect if
* it's enabled via the `experimental.ppr = "incremental"` option.
*/
experimental_ppr?: boolean

/**
* The runtime to use for the page.
*/
runtime?: 'edge' | 'nodejs'

/**
* The maximum duration for the page in seconds.
*/
maxDuration?: number
}

/**
* The keys of the configuration for a page.
*
* @internal
*/
export const AppSegmentConfigSchemaKeys = AppSegmentConfigSchema.keyof().options
175 changes: 175 additions & 0 deletions packages/next/src/build/app-segments/collect-app-segments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import type { LoadComponentsReturnType } from '../../server/load-components'
import type { Params } from '../../server/request/params'
import type {
AppPageRouteModule,
AppPageModule,
} from '../../server/route-modules/app-page/module.compiled'
import type {
AppRouteRouteModule,
AppRouteModule,
} from '../../server/route-modules/app-route/module.compiled'
import {
type AppSegmentConfig,
AppSegmentConfigSchema,
} from './app-segment-config'

import { InvariantError } from '../../shared/lib/invariant-error'
import {
isAppRouteRouteModule,
isAppPageRouteModule,
} from '../../server/route-modules/checks'
import { isClientReference } from '../../lib/client-reference'
import { getSegmentParam } from '../../server/app-render/get-segment-param'
import { getLayoutOrPageModule } from '../../server/lib/app-dir-module'

type GenerateStaticParams = (options: { params?: Params }) => Promise<Params[]>

/**
* Parses the app config and attaches it to the segment.
*/
function attach(segment: AppSegment, userland: unknown) {
// If the userland is not an object, then we can't do anything with it.
if (typeof userland !== 'object' || userland === null) {
return
}

// Try to parse the application configuration. If there were any keys, attach
// it to the segment.
const config = AppSegmentConfigSchema.safeParse(userland)
if (config.success && Object.keys(config.data).length > 0) {
segment.config = config.data
}

if (
'generateStaticParams' in userland &&
typeof userland.generateStaticParams === 'function'
) {
segment.generateStaticParams =
userland.generateStaticParams as GenerateStaticParams

// Validate that `generateStaticParams` makes sense in this context.
if (segment.config?.runtime === 'edge') {
throw new Error(
'Edge runtime is not supported with `generateStaticParams`.'
)
}
}
}

export type AppSegment = {
name: string
param: string | undefined
filePath: string | undefined
config: AppSegmentConfig | undefined
isDynamicSegment: boolean
generateStaticParams: GenerateStaticParams | undefined
}

/**
* Walks the loader tree and collects the generate parameters for each segment.
*
* @param routeModule the app page route module
* @returns the segments for the app page route module
*/
async function collectAppPageSegments(routeModule: AppPageRouteModule) {
const segments: AppSegment[] = []

let current = routeModule.userland.loaderTree
while (current) {
const [name, parallelRoutes] = current
const { mod: userland, filePath } = await getLayoutOrPageModule(current)

const isClientComponent: boolean = userland && isClientReference(userland)
const isDynamicSegment = /^\[.*\]$/.test(name)
const param = isDynamicSegment ? getSegmentParam(name)?.param : undefined

const segment: AppSegment = {
name,
param,
filePath,
config: undefined,
isDynamicSegment,
generateStaticParams: undefined,
}

// Only server components can have app segment configurations. If this isn't
// an object, then we should skip it. This can happen when parsing the
// error components.
if (!isClientComponent) {
attach(segment, userland)
}

segments.push(segment)

// Use this route's parallel route children as the next segment.
current = parallelRoutes.children
}

return segments
}

/**
* Collects the segments for a given app route module.
*
* @param routeModule the app route module
* @returns the segments for the app route module
*/
function collectAppRouteSegments(
routeModule: AppRouteRouteModule
): AppSegment[] {
// Get the pathname parts, slice off the first element (which is empty).
const parts = routeModule.definition.pathname.split('/').slice(1)
if (parts.length === 0) {
throw new InvariantError('Expected at least one segment')
}

// Generate all the segments.
const segments: AppSegment[] = parts.map((name) => {
const isDynamicSegment = /^\[.*\]$/.test(name)
const param = isDynamicSegment ? getSegmentParam(name)?.param : undefined

return {
name,
param,
filePath: undefined,
isDynamicSegment,
config: undefined,
generateStaticParams: undefined,
}
})

// We know we have at least one, we verified this above. We should get the
// last segment which represents the root route module.
const segment = segments[segments.length - 1]

segment.filePath = routeModule.definition.filename

// Extract the segment config from the userland module.
attach(segment, routeModule.userland)

return segments
}

/**
* Collects the segments for a given route module.
*
* @param components the loaded components
* @returns the segments for the route module
*/
export function collectSegments({
routeModule,
}: LoadComponentsReturnType<AppPageModule | AppRouteModule>):
| Promise<AppSegment[]>
| AppSegment[] {
if (isAppRouteRouteModule(routeModule)) {
return collectAppRouteSegments(routeModule)
}

if (isAppPageRouteModule(routeModule)) {
return collectAppPageSegments(routeModule)
}

throw new InvariantError(
'Expected a route module to be one of app route or page'
)
}
5 changes: 3 additions & 2 deletions packages/next/src/build/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,8 @@ import {
collectMeta,
// getSupportedBrowsers,
} from './utils'
import type { PageInfo, PageInfos, AppConfig, PrerenderedRoute } from './utils'
import type { PageInfo, PageInfos, PrerenderedRoute } from './utils'
import type { AppSegmentConfig } from './app-segments/app-segment-config'
import { writeBuildId } from './write-build-id'
import { normalizeLocalePath } from '../shared/lib/i18n/normalize-locale-path'
import isError from '../lib/is-error'
Expand Down Expand Up @@ -1818,7 +1819,7 @@ export default async function build(
const staticPaths = new Map<string, PrerenderedRoute[]>()
const appNormalizedPaths = new Map<string, string>()
const fallbackModes = new Map<string, FallbackMode>()
const appDefaultConfigs = new Map<string, AppConfig>()
const appDefaultConfigs = new Map<string, AppSegmentConfig>()
const pageInfos: PageInfos = new Map<string, PageInfo>()
let pagesManifest = await readManifest<PagesManifest>(pagesManifestPath)
const buildManifest = await readManifest<BuildManifest>(buildManifestPath)
Expand Down
Loading
Loading