-
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 10 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 |
---|---|---|
@@ -0,0 +1,13 @@ | ||
import destr from 'destr' | ||
|
||
/** | ||
* If this flag is enabled, we generate one function per API Route. | ||
Skn0tt marked this conversation as resolved.
Show resolved
Hide resolved
|
||
* We'll also use the "none" bundling strategy where we fully rely on Next.js' `.nft.json` files. | ||
* This should lead to smaller bundle sizes at the same speed, but is still experimental. | ||
* | ||
* If disabled, we bundle all API Routes into a single function. | ||
* This is fast, but can lead to large bundle sizes. | ||
* | ||
* Enabled by default. Can be disabled by passing NEXT_SPLIT_API_ROUTES=false. | ||
*/ | ||
export const SPLIT_API_ROUTES = destr(process.env.NEXT_SPLIT_API_ROUTES ?? 'true') |
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 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,14 +23,18 @@ 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 { 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 const generateFunctions = async ( | ||
|
@@ -71,6 +77,7 @@ 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 | ||
|
@@ -196,6 +203,26 @@ 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 |
||
|
||
// for some reason, NFT detects /bin/sh. let's not upload that to lambda. | ||
fileList.delete('bin/sh') | ||
|
||
return [...fileList].map((f) => `/${f}`).map((file) => relative(baseDir, file)) | ||
} | ||
|
||
/** | ||
* Look for API routes, and extract the config from the source file. | ||
*/ | ||
|
@@ -207,10 +234,42 @@ export const getApiRouteConfigs = async (publish: string, baseDir: string): Prom | |
const pagesDir = join(baseDir, 'pages') | ||
const srcPagesDir = join(baseDir, 'src', 'pages') | ||
|
||
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] })), '**', '*')), | ||
]) | ||
|
||
const commonDependencies = [...requiredServerFiles, ...nextServerFiles, ...followRedirectsFiles] | ||
|
||
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) | ||
|
||
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 = [ | ||
...commonDependencies, | ||
|
||
compiledPath, | ||
join('.netlify', 'functions-internal', functionName, '**', '*'), | ||
...routeDependencies, | ||
] | ||
|
||
return { | ||
functionName, | ||
route: apiRoute, | ||
config, | ||
compiled, | ||
includedFiles, | ||
} | ||
}), | ||
) | ||
} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,24 +1,92 @@ | ||
import { getExtendedApiRouteConfigs } from "../../packages/runtime/src/helpers/functions" | ||
import { describeCwdTmpDir, moveNextDist } from "../test-utils" | ||
import { getApiRouteConfigs, getExtendedApiRouteConfigs } from '../../packages/runtime/src/helpers/functions' | ||
import { describeCwdTmpDir, moveNextDist } from '../test-utils' | ||
|
||
describeCwdTmpDir('api route file analysis', () => { | ||
it('extracts correct route configs from source files', async () => { | ||
await moveNextDist() | ||
const configs = await getApiRouteConfigs('.next', process.cwd()) | ||
// Using a Set means the order doesn't matter | ||
expect(new Set(configs.map(({ includedFiles, ...rest }) => rest))).toEqual( | ||
new Set([ | ||
{ | ||
functionName: "_api_og-handler", | ||
compiled: 'pages/api/og.js', | ||
config: { | ||
runtime: 'edge', | ||
}, | ||
route: '/api/og', | ||
}, | ||
{ | ||
functionName: "_api_enterPreview-handler", | ||
compiled: 'pages/api/enterPreview.js', | ||
config: {}, | ||
route: '/api/enterPreview', | ||
}, | ||
{ | ||
functionName: "_api_exitPreview-handler", | ||
compiled: 'pages/api/exitPreview.js', | ||
config: {}, | ||
route: '/api/exitPreview', | ||
}, | ||
{ | ||
functionName: "_api_hello-handler", | ||
compiled: 'pages/api/hello.js', | ||
config: {}, | ||
route: '/api/hello', | ||
}, | ||
{ | ||
functionName: "_api_shows_params-SPLAT-handler", | ||
compiled: 'pages/api/shows/[...params].js', | ||
config: {}, | ||
route: '/api/shows/[...params]', | ||
}, | ||
{ | ||
functionName: "_api_shows_id-PARAM-handler", | ||
compiled: 'pages/api/shows/[id].js', | ||
config: {}, | ||
route: '/api/shows/[id]', | ||
}, | ||
{ | ||
functionName: "_api_hello-background-background", | ||
compiled: 'pages/api/hello-background.js', | ||
config: { type: 'experimental-background' }, | ||
route: '/api/hello-background', | ||
}, | ||
{ | ||
functionName: "_api_hello-scheduled-handler", | ||
compiled: 'pages/api/hello-scheduled.js', | ||
config: { schedule: '@hourly', type: 'experimental-scheduled' }, | ||
route: '/api/hello-scheduled', | ||
}, | ||
{ | ||
functionName: "_api_revalidate-handler", | ||
compiled: 'pages/api/revalidate.js', | ||
config: {}, | ||
route: '/api/revalidate', | ||
}, | ||
]), | ||
) | ||
}) | ||
|
||
it('only shows scheduled/background functions as extended funcs', async () => { | ||
await moveNextDist() | ||
const configs = await getExtendedApiRouteConfigs('.next', process.cwd()) | ||
// Using a Set means the order doesn't matter | ||
expect(new Set(configs)).toEqual( | ||
expect(new Set(configs.map(({ includedFiles, ...rest }) => rest))).toEqual( | ||
new Set([ | ||
{ | ||
functionName: "_api_hello-background-background", | ||
compiled: 'pages/api/hello-background.js', | ||
config: { type: 'experimental-background' }, | ||
route: '/api/hello-background', | ||
}, | ||
{ | ||
functionName: "_api_hello-scheduled-handler", | ||
compiled: 'pages/api/hello-scheduled.js', | ||
config: { schedule: '@hourly', type: 'experimental-scheduled' }, | ||
route: '/api/hello-scheduled', | ||
}, | ||
]), | ||
) | ||
}) | ||
}) | ||
}) |
Uh oh!
There was an error while loading. Please reload this page.