Skip to content

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

Merged
merged 75 commits into from
May 12, 2023
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
75 commits
Select commit Hold shift + click to select a range
418c46d
feat: split up API Routes
Skn0tt Apr 20, 2023
6e27836
feat: load includedFiles for every page
Skn0tt Apr 20, 2023
c09022c
refactor: extract function config logic
Skn0tt Apr 20, 2023
2ffd7a2
refactor: extract flag into own definition
Skn0tt Apr 20, 2023
0844815
feat: use "none" bundler for split-up api routes
Skn0tt Apr 20, 2023
60e71de
feat: list some more dependencies
Skn0tt Apr 20, 2023
caf4b69
feat: use NFT to trace common required files
Skn0tt Apr 21, 2023
d812eb6
refactor: clean up a wee bit
Skn0tt Apr 21, 2023
eb2abe8
fix: please don't include /sh
Skn0tt Apr 21, 2023
0f9cf0f
feat: enable flag by default, so tests use it
Skn0tt Apr 21, 2023
699ba69
feat: add a naïve packing algo
Skn0tt Apr 24, 2023
0a4fc56
feat: write rough sketch for packing lambdas
Skn0tt Apr 24, 2023
4cc2aff
refactor: add constructor for APILambda
Skn0tt Apr 24, 2023
12ebf75
feat: pack handlers together into bundles
Skn0tt Apr 25, 2023
3623916
fix: linter
Skn0tt Apr 25, 2023
45c3834
feat: exclude some heavy unneeded files
Skn0tt Apr 25, 2023
029dd98
Merge branch 'main' into split-api-routes
Skn0tt Apr 25, 2023
e5c4f23
fix: trigger CI again, now that it supports `none` bundler
Skn0tt Apr 25, 2023
57772f2
feat: remove code for old mechanism
Skn0tt Apr 25, 2023
567692d
fix: remove test for deleted code
Skn0tt Apr 25, 2023
7c0daa8
fix: ensure that react doesn't try to load development build
Skn0tt Apr 25, 2023
abec92a
fix: move test directory into repo, so node_modules can be read
Skn0tt Apr 26, 2023
63b8a73
fix: snapshot with API redirects
Skn0tt Apr 26, 2023
94de480
fix: remove .only
Skn0tt Apr 26, 2023
1d43d6b
Merge branch 'main' into split-api-routes
Skn0tt Apr 26, 2023
7af428b
fix: don't assert on _api_*
Skn0tt Apr 26, 2023
8664788
fix: another test
Skn0tt Apr 26, 2023
1ed2b9c
fix: apply NODE_ENV=prod to right generated handler
Skn0tt Apr 26, 2023
8807dd6
Merge branch 'main' into split-api-routes
Skn0tt Apr 27, 2023
eef0c6c
feat: remove nft tracing
Skn0tt May 2, 2023
0f5a144
feat: put change behind flag again
Skn0tt May 2, 2023
ea47ceb
feat: source flag from plugin input
Skn0tt May 2, 2023
cf24ee1
fix: add default value for featureflags
Skn0tt May 2, 2023
d757ead
Merge branch 'main' into split-api-routes
Skn0tt May 3, 2023
bb4d4cf
fix: default flag to true for testing
Skn0tt May 3, 2023
bf2983b
fix: revert changes to lockfiles
Skn0tt May 3, 2023
34257d8
fix: eslint it/test
Skn0tt May 3, 2023
c8e728c
fix: lint
Skn0tt May 3, 2023
52b79f8
fix: revert distracting change
Skn0tt May 3, 2023
2a4ceae
fix: now that we don't use nft anymore, we don't have to copy over no…
Skn0tt May 3, 2023
9d76dd4
Merge remote-tracking branch 'origin/main' into split-api-routes
nickytonline May 3, 2023
c896db1
fix: swallow require.resolve errors for unit tests
Skn0tt May 4, 2023
16aa3a1
fix: remove timing logs
Skn0tt May 4, 2023
dbaf631
fix: lint name
Skn0tt May 4, 2023
25190c6
Merge branch 'main' into split-api-routes
Skn0tt May 5, 2023
84cceb3
fix: lint
Skn0tt May 5, 2023
b09317e
fix: isr needs _document.js
Skn0tt May 5, 2023
3c19e25
fix: add _app for ISR
Skn0tt May 5, 2023
157f29d
fix: correct wrong output of some npm versions
Skn0tt May 5, 2023
78a2753
fix: integration test
Skn0tt May 5, 2023
fc371c3
fix: assemble npm package path correctly, also for monorepos
Skn0tt May 5, 2023
5131d74
fix: try what happens if we use next-netlify server
Skn0tt May 8, 2023
7a8df05
fix: check what happens when we skip revalidation
Skn0tt May 8, 2023
df0ea5a
fix: dont let revalidate request time out
Skn0tt May 8, 2023
25e8a99
fix: send x-nextjs-cache header to prevent error
Skn0tt May 8, 2023
c78e10d
fix: update error message in test
Skn0tt May 8, 2023
9216fea
fix: resolve relative paths based on data in required-server-files
Skn0tt May 8, 2023
d40beb0
fix: test
Skn0tt May 8, 2023
71f7bf2
fix: keep manually-added `included_files`
Skn0tt May 8, 2023
29a40a7
Merge branch 'main' into split-api-routes
Skn0tt May 8, 2023
118bf89
fix: try something
Skn0tt May 8, 2023
008014a
fix: don't include unneeded _app pages in ISR
Skn0tt May 8, 2023
0a44c6d
fix: finalize includedFiles before writing it onto netlifyConfig
Skn0tt May 8, 2023
0f4b522
chore: update comment
Skn0tt May 8, 2023
b799a19
fix: exclude sass file in monorepos
Skn0tt May 8, 2023
70a4fb2
Update packages/runtime/src/helpers/functions.ts
Skn0tt May 8, 2023
89fd2fb
chore: remove comment
Skn0tt May 8, 2023
f9f726f
fix: update flag impl
Skn0tt May 11, 2023
88f9b3e
refactor: use getRequiredServerFiles
Skn0tt May 11, 2023
223990e
chore: add comment on route[0]
Skn0tt May 11, 2023
6089296
fix: set NEXT_SPLIT_API_ROUTES in netlify.toml
Skn0tt May 11, 2023
30db86a
fix: put updated revalidate behaviour behind flag
Skn0tt May 11, 2023
e3c2c4d
fix: supply splitApiRoutes in getHandler
Skn0tt May 11, 2023
6287e15
fix: better run your code before committing it and embarrassing yourself
Skn0tt May 11, 2023
fdcf8d5
Merge branch 'main' into split-api-routes
Skn0tt May 12, 2023
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
238 changes: 66 additions & 172 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions packages/runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"@netlify/esbuild": "0.14.39",
"@netlify/functions": "^1.4.0",
"@netlify/ipx": "^1.4.0",
"@vercel/nft": "^0.22.6",
"@vercel/node-bridge": "^2.1.0",
"chalk": "^4.1.2",
"chokidar": "^3.5.3",
Expand Down
13 changes: 12 additions & 1 deletion packages/runtime/src/helpers/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import slash from 'slash'

import { HANDLER_FUNCTION_NAME, IMAGE_FUNCTION_NAME, ODB_FUNCTION_NAME } from '../constants'

import type { APILambda } from './functions'
import type { RoutesManifest } from './types'
import { escapeStringRegexp } from './utils'

Expand Down Expand Up @@ -98,10 +99,12 @@ export const configureHandlerFunctions = async ({
netlifyConfig,
publish,
ignore = [],
apiLambdas,
}: {
netlifyConfig: NetlifyConfig
publish: string
ignore: Array<string>
apiLambdas: APILambda[]
}) => {
const config = await getRequiredServerFiles(publish)
const files = config.files || []
Expand All @@ -117,7 +120,7 @@ export const configureHandlerFunctions = async ({
(moduleName) => !hasManuallyAddedModule({ netlifyConfig, moduleName }),
)

;[HANDLER_FUNCTION_NAME, ODB_FUNCTION_NAME, '_api_*'].forEach((functionName) => {
;[HANDLER_FUNCTION_NAME, ODB_FUNCTION_NAME].forEach((functionName: string) => {
netlifyConfig.functions[functionName] ||= { included_files: [], external_node_modules: [] }
netlifyConfig.functions[functionName].node_bundler = 'nft'
netlifyConfig.functions[functionName].included_files ||= []
Expand Down Expand Up @@ -157,6 +160,14 @@ export const configureHandlerFunctions = async ({
}
})
})

for (const apiLambda of apiLambdas) {
const { functionName, includedFiles } = apiLambda
// TODO: add all the nextRoot/wasm/excludedModules stuff from above

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?

Copy link
Contributor Author

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?

netlifyConfig.functions[functionName] ||= { included_files: [] }
netlifyConfig.functions[functionName].node_bundler = 'none'
netlifyConfig.functions[functionName].included_files = includedFiles
}
}

interface BuildHeaderParams {
Expand Down
2 changes: 1 addition & 1 deletion packages/runtime/src/helpers/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,7 @@ export const getDependenciesOfFile = async (file: string) => {
if (!existsSync(nft)) {
return []
}
const dependencies = await readJson(nft, 'utf8')
const dependencies = (await readJson(nft, 'utf8')) as { files: string[] }
return dependencies.files.map((dep) => resolve(dirname(file), dep))
}

Expand Down
193 changes: 167 additions & 26 deletions packages/runtime/src/helpers/functions.ts
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,
Expand All @@ -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,
// 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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't work out why this was no longer required?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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,
publishDir,
appDir: relative(functionDir, appDir),
})
const functionName = getFunctionNameForPage(route, config.type === ApiRouteType.BACKGROUND)

await ensureDir(join(functionsDir, functionName))

// write main API handler file
Expand All @@ -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
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)
Expand Down Expand Up @@ -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: '/' })
Copy link
Contributor

Choose a reason for hiding this comment

The 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?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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 next-server.js.nft.json already contains all we need there. That was somehow not the case in the Demo repo here, so I added the nodeFileTrace call here - ideally, we wouldn't need this at all 🤔 I'll see if we can remove this. Then we don't need to cache it :D


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

return true
})

return filtered.map((f) => `/${f}`).map((file) => relative(baseDir, file))

Choose a reason for hiding this comment

The 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
return filtered.map((f) => `/${f}`).map((file) => relative(baseDir, file))
return filtered.map((f) => relative(baseDir,`/${f}`)

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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) => {

Choose a reason for hiding this comment

The 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
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.
*/
Expand All @@ -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 {
Expand Down
39 changes: 39 additions & 0 deletions packages/runtime/src/helpers/pack.ts
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
}
8 changes: 4 additions & 4 deletions packages/runtime/src/helpers/redirects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { HANDLER_FUNCTION_PATH, HIDDEN_PATHS, ODB_FUNCTION_PATH } from '../const

import { isAppDirRoute, loadAppPathRoutesManifest } from './edge'
import { getMiddleware } from './files'
import { ApiRouteConfig } from './functions'
import { APILambda } from './functions'
import { RoutesManifest } from './types'
import {
getApiRewrites,
Expand Down Expand Up @@ -262,12 +262,12 @@ export const generateRedirects = async ({
netlifyConfig,
nextConfig: { i18n, basePath, trailingSlash, appDir },
buildId,
apiRoutes,
apiLambdas,
}: {
netlifyConfig: NetlifyConfig
nextConfig: Pick<NextConfig, 'i18n' | 'basePath' | 'trailingSlash' | 'appDir'>
buildId: string
apiRoutes: Array<ApiRouteConfig>
apiLambdas: APILambda[]
}) => {
const { dynamicRoutes: prerenderedDynamicRoutes, routes: prerenderedStaticRoutes }: PrerenderManifest =
await readJSON(join(netlifyConfig.build.publish, 'prerender-manifest.json'))
Expand All @@ -285,7 +285,7 @@ export const generateRedirects = async ({
// This is only used in prod, so dev uses `next dev` directly
netlifyConfig.redirects.push(
// API routes always need to be served from the regular function
...getApiRewrites(basePath, apiRoutes),
...getApiRewrites(basePath, apiLambdas),
// Preview mode gets forced to the function, to bypass pre-rendered pages, but static files need to be skipped
...(await getPreviewRewrites({ basePath, appDir })),
)
Expand Down
Loading