Skip to content

Commit

Permalink
refactor: added more strict app segment config parsing
Browse files Browse the repository at this point in the history
  • Loading branch information
wyattjoh committed Sep 30, 2024
1 parent 606470a commit 33372ee
Show file tree
Hide file tree
Showing 13 changed files with 501 additions and 328 deletions.
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', 'experimental-edge']).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' | 'experimental-edge'

/**
* 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
182 changes: 182 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,182 @@
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[]>

/**
* Filters out segments that don't contribute to static generation.
*
* @param segments the segments to filter
* @returns the filtered segments
*/
function filterSegments(segments: AppSegment[]) {
return segments.filter((result) => {
return (
result.config || result.generateStaticParams || result.isDynamicSegment
)
})
}

/**
* 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
}
}

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 filterSegments(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 filterSegments(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

0 comments on commit 33372ee

Please sign in to comment.