-
Notifications
You must be signed in to change notification settings - Fork 89
feat: split up API Routes + use .nft.json files to make builds fast #2058
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
Changes from 67 commits
418c46d
6e27836
c09022c
2ffd7a2
0844815
60e71de
caf4b69
d812eb6
eb2abe8
0f9cf0f
699ba69
0a4fc56
4cc2aff
12ebf75
3623916
45c3834
029dd98
e5c4f23
57772f2
567692d
7c0daa8
abec92a
63b8a73
94de480
1d43d6b
7af428b
8664788
1ed2b9c
8807dd6
eef0c6c
0f5a144
ea47ceb
cf24ee1
d757ead
bb4d4cf
bf2983b
34257d8
c8e728c
52b79f8
2a4ceae
9d76dd4
c896db1
16aa3a1
dbaf631
25190c6
84cceb3
b09317e
3c19e25
157f29d
78a2753
fc371c3
5131d74
7a8df05
df0ea5a
25e8a99
c78e10d
9216fea
d40beb0
71f7bf2
29a40a7
118bf89
008014a
0a44c6d
0f4b522
b799a19
70a4fb2
89fd2fb
f9f726f
88f9b3e
223990e
6089296
30db86a
e3c2c4d
6287e15
fdcf8d5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
/** | ||
* If this flag is enabled, we generate individual Lambda functions for API Routes. | ||
* They're packed together in 50mb chunks to avoid hitting the Lambda size limit. | ||
* | ||
* To prevent bundling times from rising, | ||
* we use the "none" bundling strategy where we fully rely on Next.js' `.nft.json` files. | ||
* This should to a significant speedup, but is still experimental. | ||
* | ||
* If disabled, we bundle all API Routes into a single function. | ||
* This is can lead to large bundle sizes. | ||
* | ||
* Enabled by default. Can be disabled by passing NEXT_SPLIT_API_ROUTES=false. | ||
*/ | ||
|
||
export const splitApiRoutes = (featureFlags: Record<string, unknown>): boolean => { | ||
if (process.env.NEXT_SPLIT_API_ROUTES) { | ||
return process.env.NEXT_SPLIT_API_ROUTES === 'true' | ||
Skn0tt marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
// default to true during testing, swap to false before merging | ||
return typeof featureFlags.next_split_api_routes === 'boolean' ? featureFlags.next_split_api_routes : true | ||
Skn0tt marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,10 +2,11 @@ import type { NetlifyConfig, NetlifyPluginConstants } from '@netlify/build' | |
import bridgeFile from '@vercel/node-bridge' | ||
import chalk from 'chalk' | ||
import destr from 'destr' | ||
import { copyFile, ensureDir, existsSync, readJSON, writeFile, writeJSON } from 'fs-extra' | ||
import { copyFile, ensureDir, existsSync, readJSON, writeFile, writeJSON, stat } from 'fs-extra' | ||
import type { ImageConfigComplete, RemotePattern } from 'next/dist/shared/lib/image-config' | ||
import { outdent } from 'outdent' | ||
import { join, relative, resolve } from 'pathe' | ||
import { join, relative, resolve, dirname } from 'pathe' | ||
import glob from 'tiny-glob' | ||
|
||
import { | ||
HANDLER_FUNCTION_NAME, | ||
|
@@ -21,21 +22,31 @@ import { getHandler } from '../templates/getHandler' | |
import { getResolverForPages, getResolverForSourceFiles } from '../templates/getPageResolver' | ||
|
||
import { ApiConfig, extractConfigFromFile, isEdgeConfig } from './analysis' | ||
import { getServerFile, getSourceFileForPage } from './files' | ||
import { getDependenciesOfFile, getServerFile, getSourceFileForPage } from './files' | ||
import { writeFunctionConfiguration } from './functionsMetaData' | ||
import { pack } from './pack' | ||
import { ApiRouteType } from './types' | ||
import { getFunctionNameForPage } from './utils' | ||
|
||
export interface ApiRouteConfig { | ||
functionName: string | ||
route: string | ||
config: ApiConfig | ||
compiled: string | ||
includedFiles: string[] | ||
} | ||
|
||
export interface APILambda { | ||
functionName: string | ||
routes: ApiRouteConfig[] | ||
includedFiles: string[] | ||
type?: ApiRouteType | ||
} | ||
|
||
export const generateFunctions = async ( | ||
{ FUNCTIONS_SRC = DEFAULT_FUNCTIONS_SRC, INTERNAL_FUNCTIONS_SRC, PUBLISH_DIR }: NetlifyPluginConstants, | ||
appDir: string, | ||
apiRoutes: Array<ApiRouteConfig>, | ||
apiLambdas: APILambda[], | ||
): Promise<void> => { | ||
const publish = resolve(PUBLISH_DIR) | ||
const functionsDir = resolve(INTERNAL_FUNCTIONS_SRC || FUNCTIONS_SRC) | ||
|
@@ -47,19 +58,16 @@ export const generateFunctions = async ( | |
? relative(functionDir, nextServerModuleAbsoluteLocation) | ||
: undefined | ||
|
||
for (const { route, config, compiled } of apiRoutes) { | ||
// Don't write a lambda if the runtime is edge | ||
if (isEdgeConfig(config.runtime)) { | ||
continue | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can't work out why this was no longer required? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
} | ||
const apiHandlerSource = await getApiHandler({ | ||
page: route, | ||
config, | ||
for (const apiLambda of apiLambdas) { | ||
const { functionName, routes, type, includedFiles } = apiLambda | ||
|
||
const apiHandlerSource = getApiHandler({ | ||
schedule: type === ApiRouteType.SCHEDULED ? routes[0].config.schedule : undefined, | ||
Skn0tt marked this conversation as resolved.
Show resolved
Hide resolved
|
||
publishDir, | ||
appDir: relative(functionDir, appDir), | ||
nextServerModuleRelativeLocation, | ||
}) | ||
const functionName = getFunctionNameForPage(route, config.type === ApiRouteType.BACKGROUND) | ||
|
||
await ensureDir(join(functionsDir, functionName)) | ||
|
||
// write main API handler file | ||
|
@@ -78,16 +86,25 @@ export const generateFunctions = async ( | |
|
||
const resolveSourceFile = (file: string) => join(publish, 'server', file) | ||
|
||
// TODO: this should be unneeded once we use the `none` bundler everywhere | ||
const resolverSource = await getResolverForSourceFiles({ | ||
functionsDir, | ||
// These extra pages are always included by Next.js | ||
sourceFiles: [compiled, 'pages/_app.js', 'pages/_document.js', 'pages/_error.js'].map(resolveSourceFile), | ||
sourceFiles: [ | ||
...routes.map((route) => route.compiled), | ||
'pages/_app.js', | ||
'pages/_document.js', | ||
'pages/_error.js', | ||
].map(resolveSourceFile), | ||
}) | ||
await writeFile(join(functionsDir, functionName, 'pages.js'), resolverSource) | ||
|
||
const nfInternalFiles = await glob(join(functionsDir, functionName, '**')) | ||
includedFiles.push(...nfInternalFiles) | ||
} | ||
|
||
const writeHandler = async (functionName: string, functionTitle: string, isODB: boolean) => { | ||
const handlerSource = await getHandler({ | ||
const handlerSource = getHandler({ | ||
isODB, | ||
publishDir, | ||
appDir: relative(functionDir, appDir), | ||
|
@@ -208,6 +225,143 @@ export const setupImageFunction = async ({ | |
} | ||
} | ||
|
||
const traceRequiredServerFiles = async (publish: string): Promise<string[]> => { | ||
const requiredServerFilesPath = join(publish, 'required-server-files.json') | ||
const { | ||
files, | ||
relativeAppDir, | ||
config: { | ||
experimental: { outputFileTracingRoot }, | ||
}, | ||
} = (await readJSON(requiredServerFilesPath)) as { | ||
files: string[] | ||
relativeAppDir: string | ||
config: { experimental: { outputFileTracingRoot: string } } | ||
} | ||
Skn0tt marked this conversation as resolved.
Show resolved
Hide resolved
|
||
const appDirRoot = join(outputFileTracingRoot, relativeAppDir) | ||
const absoluteFiles = files.map((file) => join(appDirRoot, file)) | ||
|
||
absoluteFiles.push(requiredServerFilesPath) | ||
|
||
return absoluteFiles | ||
} | ||
|
||
const traceNextServer = async (publish: string): Promise<string[]> => { | ||
const nextServerDeps = await getDependenciesOfFile(join(publish, 'next-server.js')) | ||
|
||
// during testing, i've seen `next-server` contain only one line. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This feels like a weird check? What if Next.js itself changes the file and the .nft file changes correctly and this arbitrary threshold is triggered? What is the goal of this check? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've explained that here: #2058 (comment) Rephrasing, to add more context: I've seen instances locally where the |
||
// this is a sanity check to make sure we're getting all the deps. | ||
if (nextServerDeps.length < 10) { | ||
console.error(nextServerDeps) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Did you mean to leave this here? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes. I want to be sure we catch the cases where |
||
throw new Error("next-server.js.nft.json didn't contain all dependencies.") | ||
} | ||
|
||
const filtered = nextServerDeps.filter((f) => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This filter list could be its own array of strings in a const and then we could add more stuff if necessary more easily? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I could imagine that in the future, there's different kinds of patterns than |
||
// NFT detects a bunch of large development files that we don't need. | ||
if (f.endsWith('.development.js')) return false | ||
|
||
// not needed for API Routes! | ||
if (f.endsWith('node_modules/sass/sass.dart.js')) return false | ||
|
||
return true | ||
}) | ||
|
||
return filtered | ||
} | ||
|
||
export const traceNPMPackage = async (packageName: string, publish: string) => { | ||
try { | ||
return await glob(join(dirname(require.resolve(packageName, { paths: [publish] })), '**', '*'), { | ||
absolute: true, | ||
}) | ||
} catch (error) { | ||
if (process.env.NODE_ENV === 'test') { | ||
return [] | ||
} | ||
throw error | ||
} | ||
} | ||
|
||
export const getAPIPRouteCommonDependencies = async (publish: string) => { | ||
const deps = await Promise.all([ | ||
traceRequiredServerFiles(publish), | ||
traceNextServer(publish), | ||
|
||
// used by our own bridge.js | ||
traceNPMPackage('follow-redirects', publish), | ||
]) | ||
|
||
return deps.flat(1) | ||
} | ||
|
||
const sum = (arr: number[]) => arr.reduce((v, current) => v + current, 0) | ||
|
||
// TODO: cache results | ||
Skn0tt marked this conversation as resolved.
Show resolved
Hide resolved
|
||
const getBundleWeight = async (patterns: string[]) => { | ||
const sizes = await Promise.all( | ||
patterns.flatMap(async (pattern) => { | ||
const files = await glob(pattern) | ||
return Promise.all( | ||
files.map(async (file) => { | ||
const fStat = await stat(file) | ||
if (fStat.isFile()) { | ||
return fStat.size | ||
} | ||
return 0 | ||
}), | ||
) | ||
}), | ||
) | ||
|
||
return sum(sizes.flat(1)) | ||
} | ||
|
||
const MB = 1024 * 1024 | ||
|
||
export const getAPILambdas = async ( | ||
publish: string, | ||
baseDir: string, | ||
pageExtensions: string[], | ||
): Promise<APILambda[]> => { | ||
const commonDependencies = await getAPIPRouteCommonDependencies(publish) | ||
|
||
const threshold = 50 * MB - (await getBundleWeight(commonDependencies)) | ||
|
||
const apiRoutes = await getApiRouteConfigs(publish, baseDir, pageExtensions) | ||
|
||
const packFunctions = async (routes: ApiRouteConfig[], type?: ApiRouteType): Promise<APILambda[]> => { | ||
const weighedRoutes = await Promise.all( | ||
routes.map(async (route) => ({ value: route, weight: await getBundleWeight(route.includedFiles) })), | ||
) | ||
|
||
const bins = pack(weighedRoutes, threshold) | ||
|
||
return bins.map((bin, index) => ({ | ||
functionName: bin.length === 1 ? bin[0].functionName : `api-${index}`, | ||
routes: bin, | ||
includedFiles: [...commonDependencies, ...routes.flatMap((route) => route.includedFiles)], | ||
type, | ||
})) | ||
} | ||
|
||
const standardFunctions = apiRoutes.filter( | ||
(route) => | ||
!isEdgeConfig(route.config.runtime) && | ||
route.config.type !== ApiRouteType.BACKGROUND && | ||
route.config.type !== ApiRouteType.SCHEDULED, | ||
) | ||
const scheduledFunctions = apiRoutes.filter((route) => route.config.type === ApiRouteType.SCHEDULED) | ||
const backgroundFunctions = apiRoutes.filter((route) => route.config.type === ApiRouteType.BACKGROUND) | ||
|
||
const scheduledLambdas: APILambda[] = scheduledFunctions.map(packSingleFunction) | ||
|
||
const [standardLambdas, backgroundLambdas] = await Promise.all([ | ||
packFunctions(standardFunctions), | ||
packFunctions(backgroundFunctions, ApiRouteType.BACKGROUND), | ||
]) | ||
return [...standardLambdas, ...backgroundLambdas, ...scheduledLambdas] | ||
} | ||
|
||
/** | ||
* Look for API routes, and extract the config from the source file. | ||
*/ | ||
|
@@ -226,7 +380,23 @@ export const getApiRouteConfigs = async ( | |
return await Promise.all( | ||
apiRoutes.map(async (apiRoute) => { | ||
const filePath = getSourceFileForPage(apiRoute, [pagesDir, srcPagesDir], pageExtensions) | ||
return { route: apiRoute, config: await extractConfigFromFile(filePath, appDir), compiled: pages[apiRoute] } | ||
const config = await extractConfigFromFile(filePath, appDir) | ||
|
||
const functionName = getFunctionNameForPage(apiRoute, config.type === ApiRouteType.BACKGROUND) | ||
|
||
const compiled = pages[apiRoute] | ||
const compiledPath = join(publish, 'server', compiled) | ||
|
||
const routeDependencies = await getDependenciesOfFile(compiledPath) | ||
const includedFiles = [compiledPath, ...routeDependencies] | ||
|
||
return { | ||
functionName, | ||
route: apiRoute, | ||
config, | ||
compiled, | ||
includedFiles, | ||
} | ||
}), | ||
) | ||
} | ||
|
@@ -245,6 +415,13 @@ export const getExtendedApiRouteConfigs = async ( | |
return settledApiRoutes.filter((apiRoute) => apiRoute.config.type !== undefined) | ||
} | ||
|
||
export const packSingleFunction = (func: ApiRouteConfig): APILambda => ({ | ||
functionName: func.functionName, | ||
includedFiles: func.includedFiles, | ||
routes: [func], | ||
type: func.config.type, | ||
}) | ||
|
||
interface FunctionsManifest { | ||
functions: Array<{ name: string; schedule?: string }> | ||
} | ||
|
Uh oh!
There was an error while loading. Please reload this page.