-
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 21 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
Large diffs are not rendered by default.
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -1,11 +1,13 @@ | ||||||
import type { NetlifyConfig, NetlifyPluginConstants } from '@netlify/build' | ||||||
import { nodeFileTrace } from '@vercel/nft' | ||||||
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,38 +23,47 @@ import { getHandler } from '../templates/getHandler' | |||||
import { getResolverForPages, getResolverForSourceFiles } from '../templates/getPageResolver' | ||||||
|
||||||
import { ApiConfig, ApiRouteType, extractConfigFromFile, isEdgeConfig } from './analysis' | ||||||
import { getSourceFileForPage } from './files' | ||||||
import { getSourceFileForPage, getDependenciesOfFile } from './files' | ||||||
import { writeFunctionConfiguration } from './functionsMetaData' | ||||||
import { pack } from './pack' | ||||||
import { getFunctionNameForPage } from './utils' | ||||||
|
||||||
// TODO, for reviewer: I added my properties here because that was the easiest way, | ||||||
Skn0tt marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
// but is it the right spot for it? | ||||||
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) | ||||||
const functionDir = join(functionsDir, HANDLER_FUNCTION_NAME) | ||||||
const publishDir = relative(functionDir, publish) | ||||||
|
||||||
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. I've moved that logic over here: https://github.com/netlify/next-runtime/blob/eef0c6c2995e578a78f90945b51190bbb16582e6/packages/runtime/src/helpers/functions.ts#L316 |
||||||
} | ||||||
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), | ||||||
}) | ||||||
const functionName = getFunctionNameForPage(route, config.type === ApiRouteType.BACKGROUND) | ||||||
|
||||||
await ensureDir(join(functionsDir, functionName)) | ||||||
|
||||||
// write main API handler file | ||||||
|
@@ -71,16 +82,25 @@ export const generateFunctions = async ( | |||||
|
||||||
const resolveSourceFile = (file: string) => join(publish, 'server', file) | ||||||
|
||||||
// TODO: this should be unneeded once we use the `none` bundler | ||||||
Skn0tt marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
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({ isODB, publishDir, appDir: relative(functionDir, appDir) }) | ||||||
const handlerSource = getHandler({ isODB, publishDir, appDir: relative(functionDir, appDir) }) | ||||||
await ensureDir(join(functionsDir, functionName)) | ||||||
|
||||||
// write main handler file (standard or ODB) | ||||||
|
@@ -196,6 +216,121 @@ export const setupImageFunction = async ({ | |||||
} | ||||||
} | ||||||
|
||||||
const traceRequiredServerFiles = async (publish: string): Promise<string[]> => { | ||||||
const requiredServerFilesPath = join(publish, 'required-server-files.json') | ||||||
const { files } = (await readJSON(requiredServerFilesPath)) as { files: string[] } | ||||||
files.push(requiredServerFilesPath) | ||||||
return files | ||||||
} | ||||||
|
||||||
const traceNextServer = async (publish: string, baseDir: string): Promise<string[]> => { | ||||||
const nextServerDeps = await getDependenciesOfFile(join(publish, 'next-server.js')) | ||||||
|
||||||
// this takes quite a long while, but it's only done once per build. | ||||||
// theoretically, it's only needed once per version of Next.js 🤷 | ||||||
const { fileList } = await nodeFileTrace(nextServerDeps, { base: '/' }) | ||||||
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 wonder whether we could persist the result of this between builds with the build cache? 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 dabbled around with a barebones Next.js repo, and it looks like |
||||||
|
||||||
const filtered = [...fileList].filter((f) => { | ||||||
// for some reason, NFT detects /bin/sh. let's not upload that to lambda. | ||||||
if (f === 'bin/sh') return false | ||||||
|
||||||
// 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 === 'node_modules/sass/sass.dart.js') return false | ||||||
nickytonline marked this conversation as resolved.
Show resolved
Hide resolved
Skn0tt marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
|
||||||
return true | ||||||
}) | ||||||
|
||||||
return filtered.map((f) => `/${f}`).map((file) => relative(baseDir, file)) | ||||||
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 believe this is the same thing or is there a reason we're mapping twice?
Suggested change
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. yeah you're right 👍 it was removed in eef0c6c. |
||||||
} | ||||||
|
||||||
export const getAPIPRouteCommonDependencies = async (publish: string, baseDir: string) => { | ||||||
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. For exported functions, add JSDoc comments. |
||||||
const [requiredServerFiles, nextServerFiles, followRedirectsFiles] = await Promise.all([ | ||||||
traceRequiredServerFiles(publish), | ||||||
traceNextServer(publish, baseDir), | ||||||
|
||||||
// used by our own bridge.js | ||||||
glob(join(dirname(require.resolve('follow-redirects', { paths: [publish] })), '**', '*')), | ||||||
]) | ||||||
|
||||||
return [...requiredServerFiles, ...nextServerFiles, ...followRedirectsFiles] | ||||||
} | ||||||
|
||||||
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): Promise<APILambda[]> => { | ||||||
console.time('traceCommonDependencies') | ||||||
const commonDependencies = await getAPIPRouteCommonDependencies(publish, baseDir) | ||||||
console.timeEnd('traceCommonDependencies') | ||||||
|
||||||
console.time('weighCommonDependencies') | ||||||
const threshold = 50 * MB - (await getBundleWeight(commonDependencies)) | ||||||
console.timeEnd('weighCommonDependencies') | ||||||
|
||||||
const apiRoutes = await getApiRouteConfigs(publish, baseDir) | ||||||
|
||||||
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((func) => ({ | ||||||
functionName: func.functionName, | ||||||
includedFiles: func.includedFiles, | ||||||
routes: [func], | ||||||
type: func.config.type, | ||||||
})) | ||||||
|
||||||
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. | ||||||
*/ | ||||||
|
@@ -210,19 +345,25 @@ export const getApiRouteConfigs = async (publish: string, baseDir: string): Prom | |||||
return await Promise.all( | ||||||
apiRoutes.map(async (apiRoute) => { | ||||||
const filePath = getSourceFileForPage(apiRoute, [pagesDir, srcPagesDir]) | ||||||
return { route: apiRoute, config: await extractConfigFromFile(filePath), compiled: pages[apiRoute] } | ||||||
}), | ||||||
) | ||||||
} | ||||||
const config = await extractConfigFromFile(filePath) | ||||||
|
||||||
/** | ||||||
* Looks for extended API routes (background and scheduled functions) and extract the config from the source file. | ||||||
*/ | ||||||
export const getExtendedApiRouteConfigs = async (publish: string, baseDir: string): Promise<Array<ApiRouteConfig>> => { | ||||||
const settledApiRoutes = await getApiRouteConfigs(publish, baseDir) | ||||||
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] | ||||||
|
||||||
// We only want to return the API routes that are background or scheduled functions | ||||||
return settledApiRoutes.filter((apiRoute) => apiRoute.config.type !== undefined) | ||||||
return { | ||||||
functionName, | ||||||
route: apiRoute, | ||||||
config, | ||||||
compiled, | ||||||
includedFiles, | ||||||
} | ||||||
}), | ||||||
) | ||||||
} | ||||||
|
||||||
interface FunctionsManifest { | ||||||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,39 @@ | ||
/** | ||
* Naïve linear packing algorithm. | ||
* Takes items with weights, and packs them into boxes of a given threshold. | ||
* If an item weight exceeds the threshold, it is put into a box of its own. | ||
* | ||
* We're using this to combine many API Routes into fewer Lambda functions. | ||
* | ||
* This does not compute an optimal solution. | ||
* For that, we'd take the full dependency graph into account | ||
* and try to pack routes with intersecting dependencies together. | ||
* But since most of the lambda bundle consists of node_modules, | ||
* that probably won't help much. | ||
* In the future, we might think about using some graph-based analysis here! | ||
* Too complicated for now. | ||
*/ | ||
export const pack = <T>(items: { value: T; weight: number }[], threshold: number): T[][] => { | ||
const result: T[][] = [] | ||
|
||
let currentBox: T[] = [] | ||
let currentWeight = 0 | ||
|
||
const sortedDescending = items.sort((a, b) => b.weight - a.weight) | ||
for (const item of sortedDescending) { | ||
const fitsInCurrentBox = currentWeight + item.weight <= threshold | ||
if (fitsInCurrentBox) { | ||
currentBox.push(item.value) | ||
currentWeight += item.weight | ||
} else { | ||
if (currentBox.length !== 0) result.push(currentBox) | ||
|
||
currentBox = [item.value] | ||
currentWeight = item.weight | ||
} | ||
} | ||
|
||
if (currentBox.length !== 0) result.push(currentBox) | ||
|
||
return result | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this outstanding for this PR?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it is, and i've got some tests to fix - will turn into a draft for now. Do you think the general direction makes sense, though?