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 67 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
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -152,4 +152,6 @@ packages/*/lib
cypress/screenshots

# Test cases have node module fixtures
!test/**/node_modules
!test/**/node_modules

/tmp
2 changes: 1 addition & 1 deletion cypress/e2e/default/revalidate.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ describe('On-demand revalidation', () => {
cy.request({ url: '/api/revalidate/?select=5', failOnStatusCode: false }).then((res) => {
expect(res.status).to.eq(500)
expect(res.body).to.have.property('message')
expect(res.body.message).to.include('Invalid response 404')
expect(res.body.message).to.include('could not refresh content for path /getStaticProps/withRevalidate/3/, path is not handled by an odb')
})
})
it('revalidates dynamic non-prerendered ISR route with fallback blocking', () => {
Expand Down
25 changes: 23 additions & 2 deletions packages/runtime/src/helpers/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import slash from 'slash'

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

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

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

;[HANDLER_FUNCTION_NAME, ODB_FUNCTION_NAME, '_api_*'].forEach((functionName) => {
const configureFunction = (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 @@ -156,7 +162,22 @@ export const configureHandlerFunctions = async ({
netlifyConfig.functions[functionName].included_files.push(`!${moduleRoot}/**/*`)
}
})
})
}

configureFunction(HANDLER_FUNCTION_NAME)
configureFunction(ODB_FUNCTION_NAME)

if (splitApiRoutes(featureFlags)) {
for (const apiLambda of apiLambdas) {
const { functionName, includedFiles } = apiLambda
netlifyConfig.functions[functionName] ||= { included_files: [] }
netlifyConfig.functions[functionName].node_bundler = 'none'
netlifyConfig.functions[functionName].included_files ||= []
netlifyConfig.functions[functionName].included_files.push(...includedFiles)
}
} else {
configureFunction('_api_*')
}
}

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 @@ -366,7 +366,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
21 changes: 21 additions & 0 deletions packages/runtime/src/helpers/flags.ts
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'
}
// default to true during testing, swap to false before merging
return typeof featureFlags.next_split_api_routes === 'boolean' ? featureFlags.next_split_api_routes : true
}
209 changes: 193 additions & 16 deletions packages/runtime/src/helpers/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand All @@ -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
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),
nextServerModuleRelativeLocation,
})
const functionName = getFunctionNameForPage(route, config.type === ApiRouteType.BACKGROUND)

await ensureDir(join(functionsDir, functionName))

// write main API handler file
Expand All @@ -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),
Expand Down Expand Up @@ -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 } }
}
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.

Choose a reason for hiding this comment

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

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 explained that here: #2058 (comment)

Rephrasing, to add more context: I've seen instances locally where the .nft file contained less than 10 lines, but those were definitely invalid ones, and they resulted in broken bundles. I don't see a case where the .nft file correctly contains less than 10 lines. I'm unsure if the instances i've seen locally are due to not-so-common setup where the plugin is pulled in with npm link. If this happened in prod, it'd result in deployments that break at runtime. To prevent this, i'm adding this build-time check. I'll be closely monitoring it during the rollout. Once I'm certain this doesn't occur in production, I'll remove the check.

// this is a sanity check to make sure we're getting all the deps.
if (nextServerDeps.length < 10) {
console.error(nextServerDeps)
Copy link
Member

Choose a reason for hiding this comment

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

Did you mean to leave this here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes. I want to be sure we catch the cases where nextServerDeps is weirdly small, so it's best if it fails loudly as early as possible, at build time. And the console.error above the exception helps us debug things a bit faster.

throw new Error("next-server.js.nft.json didn't contain all dependencies.")
}

const filtered = nextServerDeps.filter((f) => {

Choose a reason for hiding this comment

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

Copy link
Contributor Author

@Skn0tt Skn0tt May 11, 2023

Choose a reason for hiding this comment

The 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 .endsWith that we want to match against, so having this as a filter function is the most versatile. Having said that, I'm fine with replacing this with an array of strings for now, if you prefer that.

// 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
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.
*/
Expand All @@ -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,
}
}),
)
}
Expand All @@ -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 }>
}
Expand Down
Loading