diff --git a/.github/workflows/e2e-appdir.yml b/.github/workflows/e2e-appdir.yml index 418740cc10..9f521cafdf 100644 --- a/.github/workflows/e2e-appdir.yml +++ b/.github/workflows/e2e-appdir.yml @@ -6,6 +6,9 @@ on: push: branches: [main] +env: + NEXT_SPLIT_API_ROUTES: true + jobs: setup: runs-on: ubuntu-latest diff --git a/.github/workflows/e2e-next.yml b/.github/workflows/e2e-next.yml index 0b4f5e12f6..c78a2b56d0 100644 --- a/.github/workflows/e2e-next.yml +++ b/.github/workflows/e2e-next.yml @@ -8,6 +8,9 @@ on: schedule: - cron: '0 0 * * *' +env: + NEXT_SPLIT_API_ROUTES: true + jobs: setup: runs-on: ubuntu-latest diff --git a/.github/workflows/test-integration.yml b/.github/workflows/test-integration.yml index 71866c0492..9c3dc4328f 100644 --- a/.github/workflows/test-integration.yml +++ b/.github/workflows/test-integration.yml @@ -9,6 +9,9 @@ concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} cancel-in-progress: true +env: + NEXT_SPLIT_API_ROUTES: true + jobs: build: name: Integration tests diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 39e37f242d..afc54d6ac0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,6 +11,9 @@ concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} cancel-in-progress: true +env: + NEXT_SPLIT_API_ROUTES: true + jobs: build: name: Unit tests diff --git a/.gitignore b/.gitignore index 97143df73e..a3320077b6 100644 --- a/.gitignore +++ b/.gitignore @@ -152,4 +152,6 @@ packages/*/lib cypress/screenshots # Test cases have node module fixtures -!test/**/node_modules \ No newline at end of file +!test/**/node_modules + +/tmp diff --git a/cypress/e2e/default/revalidate.cy.ts b/cypress/e2e/default/revalidate.cy.ts index e600cc30b6..56b5c53211 100644 --- a/cypress/e2e/default/revalidate.cy.ts +++ b/cypress/e2e/default/revalidate.cy.ts @@ -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', () => { diff --git a/demos/default/netlify.toml b/demos/default/netlify.toml index ca00bea4c2..e9f5355def 100644 --- a/demos/default/netlify.toml +++ b/demos/default/netlify.toml @@ -10,6 +10,7 @@ CYPRESS_CACHE_FOLDER = "../node_modules/.CypressBinary" # set TERM variable for terminal output TERM = "xterm" NODE_VERSION = "16.15.1" +NEXT_SPLIT_API_ROUTES = "true" [[headers]] for = "/_next/image/*" diff --git a/demos/middleware/netlify.toml b/demos/middleware/netlify.toml index b7a08292a9..07df900f3d 100644 --- a/demos/middleware/netlify.toml +++ b/demos/middleware/netlify.toml @@ -3,6 +3,9 @@ command = "npm run build" publish = ".next" ignore = "if [ $CACHED_COMMIT_REF == $COMMIT_REF ]; then (exit 1); else git diff --quiet $CACHED_COMMIT_REF $COMMIT_REF ../..; fi;" +[build.environment] +NEXT_SPLIT_API_ROUTES = "true" + [[plugins]] package = "@netlify/plugin-nextjs" diff --git a/demos/nx-next-monorepo-demo/netlify.toml b/demos/nx-next-monorepo-demo/netlify.toml index e853d40bd8..bdb5ed59f8 100644 --- a/demos/nx-next-monorepo-demo/netlify.toml +++ b/demos/nx-next-monorepo-demo/netlify.toml @@ -3,6 +3,9 @@ command = "npm run build" publish = "dist/apps/demo-monorepo/.next" ignore = "if [ $CACHED_COMMIT_REF == $COMMIT_REF ]; then (exit 1); else git diff --quiet $CACHED_COMMIT_REF $COMMIT_REF ../..; fi;" +[build.environment] +NEXT_SPLIT_API_ROUTES = "true" + [dev] command = "npm run start" targetPort = 4200 diff --git a/demos/static-root/netlify.toml b/demos/static-root/netlify.toml index 86b1877202..5755b5b934 100644 --- a/demos/static-root/netlify.toml +++ b/demos/static-root/netlify.toml @@ -3,6 +3,9 @@ command = "next build" publish = ".next" ignore = "if [ $CACHED_COMMIT_REF == $COMMIT_REF ]; then (exit 1); else git diff --quiet $CACHED_COMMIT_REF $COMMIT_REF ../..; fi;" +[build.environment] +NEXT_SPLIT_API_ROUTES = "true" + [[plugins]] package = "@netlify/plugin-nextjs" diff --git a/packages/runtime/src/helpers/config.ts b/packages/runtime/src/helpers/config.ts index 931c727f01..da916c2447 100644 --- a/packages/runtime/src/helpers/config.ts +++ b/packages/runtime/src/helpers/config.ts @@ -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' @@ -17,6 +19,7 @@ type NetlifyHeaders = NetlifyConfig['headers'] export interface RequiredServerFiles { version?: number + relativeAppDir?: string config?: NextConfigComplete appDir?: string files?: string[] @@ -98,10 +101,14 @@ export const configureHandlerFunctions = async ({ netlifyConfig, publish, ignore = [], + apiLambdas, + featureFlags, }: { netlifyConfig: NetlifyConfig publish: string ignore: Array + apiLambdas: APILambda[] + featureFlags: Record }) => { const config = await getRequiredServerFiles(publish) const files = config.files || [] @@ -117,7 +124,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 ||= [] @@ -156,7 +163,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 { diff --git a/packages/runtime/src/helpers/files.ts b/packages/runtime/src/helpers/files.ts index 4e747df403..28c6e3c84e 100644 --- a/packages/runtime/src/helpers/files.ts +++ b/packages/runtime/src/helpers/files.ts @@ -371,7 +371,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)) } diff --git a/packages/runtime/src/helpers/flags.ts b/packages/runtime/src/helpers/flags.ts new file mode 100644 index 0000000000..fe0755fa32 --- /dev/null +++ b/packages/runtime/src/helpers/flags.ts @@ -0,0 +1,17 @@ +import destr from 'destr' + +/** + * 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. + * + * Disabled by default. Can be overriden using the NEXT_SPLIT_API_ROUTES env var. + */ +export const splitApiRoutes = (featureFlags: Record): boolean => + destr(process.env.NEXT_SPLIT_API_ROUTES) ?? featureFlags.next_split_api_routes ?? false diff --git a/packages/runtime/src/helpers/functions.ts b/packages/runtime/src/helpers/functions.ts index 021d89a645..167653a044 100644 --- a/packages/runtime/src/helpers/functions.ts +++ b/packages/runtime/src/helpers/functions.ts @@ -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,33 @@ import { getHandler } from '../templates/getHandler' import { getResolverForPages, getResolverForSourceFiles } from '../templates/getPageResolver' import { ApiConfig, extractConfigFromFile, isEdgeConfig } from './analysis' -import { getServerFile, getSourceFileForPage } from './files' +import { getRequiredServerFiles } from './config' +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, + apiLambdas: APILambda[], + featureFlags: Record, ): Promise => { const publish = resolve(PUBLISH_DIR) const functionsDir = resolve(INTERNAL_FUNCTIONS_SRC || FUNCTIONS_SRC) @@ -47,19 +60,19 @@ 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 - } - const apiHandlerSource = await getApiHandler({ - page: route, - config, + for (const apiLambda of apiLambdas) { + const { functionName, routes, type, includedFiles } = apiLambda + + const apiHandlerSource = getApiHandler({ + // most api lambdas serve multiple routes, but scheduled functions need to be in separate lambdas. + // so routes[0] is safe to access. + schedule: type === ApiRouteType.SCHEDULED ? routes[0].config.schedule : undefined, publishDir, appDir: relative(functionDir, appDir), nextServerModuleRelativeLocation, + featureFlags, }) - const functionName = getFunctionNameForPage(route, config.type === ApiRouteType.BACKGROUND) + await ensureDir(join(functionsDir, functionName)) // write main API handler file @@ -78,16 +91,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 +230,138 @@ export const setupImageFunction = async ({ } } +const traceRequiredServerFiles = async (publish: string): Promise => { + const { + files, + relativeAppDir, + config: { + experimental: { outputFileTracingRoot }, + }, + } = await getRequiredServerFiles(publish) + const appDirRoot = join(outputFileTracingRoot, relativeAppDir) + const absoluteFiles = files.map((file) => join(appDirRoot, file)) + + absoluteFiles.push(join(publish, 'required-server-files.json')) + + return absoluteFiles +} + +const traceNextServer = async (publish: string): Promise => { + const nextServerDeps = await getDependenciesOfFile(join(publish, 'next-server.js')) + + // during testing, i've seen `next-server` contain only one line. + // this is a sanity check to make sure we're getting all the deps. + if (nextServerDeps.length < 10) { + console.error(nextServerDeps) + throw new Error("next-server.js.nft.json didn't contain all dependencies.") + } + + const filtered = nextServerDeps.filter((f) => { + // 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 => { + 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 => { + 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 }> } diff --git a/packages/runtime/src/helpers/pack.ts b/packages/runtime/src/helpers/pack.ts new file mode 100644 index 0000000000..5587981613 --- /dev/null +++ b/packages/runtime/src/helpers/pack.ts @@ -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 = (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 +} diff --git a/packages/runtime/src/helpers/redirects.ts b/packages/runtime/src/helpers/redirects.ts index 1375bc4472..c7194a3b7b 100644 --- a/packages/runtime/src/helpers/redirects.ts +++ b/packages/runtime/src/helpers/redirects.ts @@ -11,7 +11,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, @@ -267,12 +267,12 @@ export const generateRedirects = async ({ netlifyConfig, nextConfig: { i18n, basePath, trailingSlash, appDir }, buildId, - apiRoutes, + apiLambdas, }: { netlifyConfig: NetlifyConfig nextConfig: Pick buildId: string - apiRoutes: Array + apiLambdas: APILambda[] }) => { const { dynamicRoutes: prerenderedDynamicRoutes, routes: prerenderedStaticRoutes }: PrerenderManifest = await readJSON(join(netlifyConfig.build.publish, 'prerender-manifest.json')) @@ -290,7 +290,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 })), ) diff --git a/packages/runtime/src/helpers/utils.ts b/packages/runtime/src/helpers/utils.ts index 4a0ead00f1..7bb84478ab 100644 --- a/packages/runtime/src/helpers/utils.ts +++ b/packages/runtime/src/helpers/utils.ts @@ -7,7 +7,7 @@ import { join } from 'pathe' import { OPTIONAL_CATCH_ALL_REGEX, CATCH_ALL_REGEX, DYNAMIC_PARAMETER_REGEX, HANDLER_FUNCTION_PATH } from '../constants' -import type { ApiRouteConfig } from './functions' +import type { APILambda } from './functions' import { I18n, ApiRouteType } from './types' const RESERVED_FILENAME = /[^\w_-]/g @@ -197,23 +197,22 @@ export const redirectsForNextRouteWithData = ({ force, })) -export const getApiRewrites = (basePath: string, apiRoutes: Array) => { - const apiRewrites = apiRoutes.map((apiRoute) => { - const [from] = toNetlifyRoute(`${basePath}${apiRoute.route}`) +export const getApiRewrites = (basePath: string, apiLambdas: APILambda[]) => { + const apiRewrites = apiLambdas.flatMap((lambda) => + lambda.routes.map((apiRoute) => { + const [from] = toNetlifyRoute(`${basePath}${apiRoute.route}`) - // Scheduled functions can't be invoked directly, so we 404 them. - if (apiRoute.config.type === ApiRouteType.SCHEDULED) { - return { from, to: '/404.html', status: 404 } - } - return { - from, - to: `/.netlify/functions/${getFunctionNameForPage( - apiRoute.route, - apiRoute.config.type === ApiRouteType.BACKGROUND, - )}`, - status: 200, - } - }) + // Scheduled functions can't be invoked directly, so we 404 them. + if (apiRoute.config.type === ApiRouteType.SCHEDULED) { + return { from, to: '/404.html', status: 404 } + } + return { + from, + to: `/.netlify/functions/${lambda.functionName}`, + status: 200, + } + }), + ) return [ ...apiRewrites, diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 58a3e3604e..8a28c4204a 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -1,6 +1,6 @@ import { join, relative } from 'path' -import type { NetlifyPlugin } from '@netlify/build' +import type { NetlifyPlugin, NetlifyPluginOptions } from '@netlify/build' import { bold, redBright } from 'chalk' import destr from 'destr' import { existsSync, readFileSync } from 'fs-extra' @@ -18,12 +18,16 @@ import { import { onPreDev } from './helpers/dev' import { writeEdgeFunctions, loadMiddlewareManifest, cleanupEdgeFunctions } from './helpers/edge' import { moveStaticPages, movePublicFiles, patchNextFiles } from './helpers/files' +import { splitApiRoutes } from './helpers/flags' import { generateFunctions, setupImageFunction, generatePagesResolver, - getExtendedApiRouteConfigs, warnOnApiRoutes, + getAPILambdas, + packSingleFunction, + getExtendedApiRouteConfigs, + APILambda, } from './helpers/functions' import { generateRedirects, generateStaticRedirects } from './helpers/redirects' import { shouldSkip, isNextAuthInstalled, getCustomImageResponseHeaders, getRemotePatterns } from './helpers/utils' @@ -72,7 +76,8 @@ const plugin: NetlifyPlugin = { utils: { build: { failBuild }, }, - }) { + featureFlags = {}, + }: NetlifyPluginOptions & { featureFlags?: Record }) { if (shouldSkip()) { return } @@ -161,12 +166,23 @@ const plugin: NetlifyPlugin = { const buildId = readFileSync(join(publish, 'BUILD_ID'), 'utf8').trim() - await configureHandlerFunctions({ netlifyConfig, ignore, publish: relative(process.cwd(), publish) }) - const apiRoutes = await getExtendedApiRouteConfigs(publish, appDir, pageExtensions) + const apiLambdas: APILambda[] = splitApiRoutes(featureFlags) + ? await getAPILambdas(publish, appDir, pageExtensions) + : await getExtendedApiRouteConfigs(publish, appDir, pageExtensions).then((extendedRoutes) => + extendedRoutes.map(packSingleFunction), + ) - await generateFunctions(constants, appDir, apiRoutes) + await generateFunctions(constants, appDir, apiLambdas, featureFlags) await generatePagesResolver(constants) + await configureHandlerFunctions({ + netlifyConfig, + ignore, + publish: relative(process.cwd(), publish), + apiLambdas, + featureFlags, + }) + await movePublicFiles({ appDir, outdir, publish, basePath }) await patchNextFiles(appDir) @@ -193,7 +209,7 @@ const plugin: NetlifyPlugin = { netlifyConfig, nextConfig: { basePath, i18n, trailingSlash, appDir }, buildId, - apiRoutes, + apiLambdas, }) await writeEdgeFunctions({ netlifyConfig, routesManifest }) diff --git a/packages/runtime/src/templates/getApiHandler.ts b/packages/runtime/src/templates/getApiHandler.ts index d42bf4a7fe..f35a428f2a 100644 --- a/packages/runtime/src/templates/getApiHandler.ts +++ b/packages/runtime/src/templates/getApiHandler.ts @@ -3,11 +3,11 @@ import type { Bridge as NodeBridge } from '@vercel/node-bridge/bridge' // Aliasing like this means the editor may be able to syntax-highlight the string import { outdent as javascript } from 'outdent' -import { ApiConfig } from '../helpers/analysis' import type { NextConfig } from '../helpers/config' -import { ApiRouteType } from '../helpers/types' +import { splitApiRoutes as isSplitApiRoutesEnabled } from '../helpers/flags' import type { NextServerType } from './handlerUtils' +import type { NetlifyNextServerType } from './server' /* eslint-disable @typescript-eslint/no-var-requires */ @@ -19,6 +19,7 @@ const { URLSearchParams, URL } = require('url') const { Bridge } = require('@vercel/node-bridge/bridge') const { getMultiValueHeaders } = require('./handlerUtils') +const { getNetlifyNextServer } = require('./server') /* eslint-enable @typescript-eslint/no-var-requires */ type Mutable = { @@ -29,12 +30,12 @@ type MakeApiHandlerParams = { conf: NextConfig app: string pageRoot: string - page: string NextServer: NextServerType + splitApiRoutes: boolean } // We return a function and then call `toString()` on it to serialise it as the launcher function -const makeApiHandler = ({ conf, app, pageRoot, page, NextServer }: MakeApiHandlerParams) => { +const makeApiHandler = ({ conf, app, pageRoot, NextServer, splitApiRoutes }: MakeApiHandlerParams) => { // Change working directory into the site root, unless using Nx, which moves the // dist directory and handles this itself const dir = path.resolve(__dirname, app) @@ -48,6 +49,8 @@ const makeApiHandler = ({ conf, app, pageRoot, page, NextServer }: MakeApiHandle require.resolve('./pages.js') } catch {} + const NetlifyNextServer: NetlifyNextServerType = getNetlifyNextServer(NextServer) + // React assumes you want development mode if NODE_ENV is unset. ;(process.env as Mutable).NODE_ENV ||= 'production' @@ -64,21 +67,32 @@ const makeApiHandler = ({ conf, app, pageRoot, page, NextServer }: MakeApiHandle // We memoize this because it can be shared between requests, but don't instantiate it until // the first request because we need the host and port. let bridge: NodeBridge - const getBridge = (event: HandlerEvent): NodeBridge => { + const getBridge = (event: HandlerEvent, context: HandlerContext): NodeBridge => { if (bridge) { return bridge } + + const { + clientContext: { custom: customContext }, + } = context + // Scheduled functions don't have a URL, but we need to give one so Next knows the route to serve const url = event.rawUrl ? new URL(event.rawUrl) : new URL(path, process.env.URL || 'http://n') const port = Number.parseInt(url.port) || 80 - const nextServer = new NextServer({ - conf, - dir, - customServer: false, - hostname: url.hostname, - port, - }) + const nextServer = new NetlifyNextServer( + { + conf, + dir, + customServer: false, + hostname: url.hostname, + port, + }, + { + revalidateToken: customContext?.odb_refresh_hooks, + splitApiRoutes, + }, + ) const requestHandler = nextServer.getRequestHandler() const server = new Server(async (req, res) => { try { @@ -95,13 +109,11 @@ const makeApiHandler = ({ conf, app, pageRoot, page, NextServer }: MakeApiHandle return async function handler(event: HandlerEvent, context: HandlerContext) { // Ensure that paths are encoded - but don't double-encode them - event.path = event.rawUrl ? new URL(event.rawUrl).pathname : page + event.path = new URL(event.rawUrl).pathname // Next expects to be able to parse the query from the URL const query = new URLSearchParams(event.queryStringParameters).toString() event.path = query ? `${event.path}?${query}` : event.path - // We know the page - event.headers['x-matched-path'] = page - const { headers, ...result } = await getBridge(event).launcher(event, context) + const { headers, ...result } = await getBridge(event, context).launcher(event, context) // Convert all headers to multiValueHeaders @@ -121,20 +133,21 @@ const makeApiHandler = ({ conf, app, pageRoot, page, NextServer }: MakeApiHandle * Handlers for API routes are simpler than page routes, but they each have a separate one */ export const getApiHandler = ({ - page, - config, + schedule, publishDir = '../../../.next', appDir = '../../..', nextServerModuleRelativeLocation, + featureFlags, }: { - page: string - config: ApiConfig + schedule?: string publishDir?: string appDir?: string nextServerModuleRelativeLocation: string | undefined + featureFlags: Record }): string => - // This is a string, but if you have the right editor plugin it should format as js + // This is a string, but if you have the right editor plugin it should format as js (e.g. bierner.comment-tagged-templates in VS Code) javascript/* javascript */ ` + process.env.NODE_ENV = 'production'; if (!${JSON.stringify(nextServerModuleRelativeLocation)}) { throw new Error('Could not find Next.js server') } @@ -143,19 +156,18 @@ export const getApiHandler = ({ // We copy the file here rather than requiring from the node module const { Bridge } = require("./bridge"); const { getMultiValueHeaders } = require('./handlerUtils') + const { getNetlifyNextServer } = require('./server') const NextServer = require(${JSON.stringify(nextServerModuleRelativeLocation)}).default - ${config.type === ApiRouteType.SCHEDULED ? `const { schedule } = require("@netlify/functions")` : ''} + ${schedule ? `const { schedule } = require("@netlify/functions")` : ''} const { config } = require("${publishDir}/required-server-files.json") let staticManifest const path = require("path"); const pageRoot = path.resolve(path.join(__dirname, "${publishDir}", "server")); - const handler = (${makeApiHandler.toString()})({ conf: config, app: "${appDir}", pageRoot, page:${JSON.stringify( - page, - )}, NextServer}) - exports.handler = ${ - config.type === ApiRouteType.SCHEDULED ? `schedule(${JSON.stringify(config.schedule)}, handler);` : 'handler' - } + const handler = (${makeApiHandler.toString()})({ conf: config, app: "${appDir}", pageRoot, NextServer, splitApiRoutes: ${isSplitApiRoutesEnabled( + featureFlags, + )} }) + exports.handler = ${schedule ? `schedule(${JSON.stringify(schedule)}, handler);` : 'handler'} ` diff --git a/packages/runtime/src/templates/getHandler.ts b/packages/runtime/src/templates/getHandler.ts index 5d7d2b5ecb..7e1d2b8f36 100644 --- a/packages/runtime/src/templates/getHandler.ts +++ b/packages/runtime/src/templates/getHandler.ts @@ -101,6 +101,7 @@ const makeHandler = ({ conf, app, pageRoot, NextServer, staticManifest = [], mod }, { revalidateToken: customContext?.odb_refresh_hooks, + splitApiRoutes: false, }, ) const requestHandler = nextServer.getRequestHandler() @@ -189,7 +190,7 @@ export const getHandler = ({ appDir = '../../..', nextServerModuleRelativeLocation, }): string => - // This is a string, but if you have the right editor plugin it should format as js + // This is a string, but if you have the right editor plugin it should format as js (e.g. bierner.comment-tagged-templates in VS Code) javascript/* javascript */ ` if (!${JSON.stringify(nextServerModuleRelativeLocation)}) { throw new Error('Could not find Next.js server') diff --git a/packages/runtime/src/templates/server.ts b/packages/runtime/src/templates/server.ts index 7fb61661eb..e5fb2e742c 100644 --- a/packages/runtime/src/templates/server.ts +++ b/packages/runtime/src/templates/server.ts @@ -1,4 +1,5 @@ import { PrerenderManifest } from 'next/dist/build' +import type { BaseNextResponse } from 'next/dist/server/base-http' import { NodeRequestHandler, Options } from 'next/dist/server/next-server' import { @@ -12,6 +13,7 @@ import { interface NetlifyConfig { revalidateToken?: string + splitApiRoutes: boolean } const getNetlifyNextServer = (NextServer: NextServerType) => { @@ -36,11 +38,26 @@ const getNetlifyNextServer = (NextServer: NextServerType) => { return async (req, res, parsedUrl) => { // preserve the URL before Next.js mutates it for i18n const { url, headers } = req - // handle the original res.revalidate() request - await handler(req, res, parsedUrl) - // handle on-demand revalidation by purging the ODB cache - if (res.statusCode === 200 && headers['x-prerender-revalidate'] && this.netlifyConfig.revalidateToken) { - await this.netlifyRevalidate(url) + + if (this.netlifyConfig.splitApiRoutes) { + if (headers['x-prerender-revalidate'] && this.netlifyConfig.revalidateToken) { + // handle on-demand revalidation by purging the ODB cache + await this.netlifyRevalidate(url) + + res = res as unknown as BaseNextResponse + res.statusCode = 200 + res.setHeader('x-nextjs-cache', 'REVALIDATED') + res.send() + } else { + await handler(req, res, parsedUrl) + } + } else { + // handle the original res.revalidate() request + await handler(req, res, parsedUrl) + // handle on-demand revalidation by purging the ODB cache + if (res.statusCode === 200 && headers['x-prerender-revalidate'] && this.netlifyConfig.revalidateToken) { + await this.netlifyRevalidate(url) + } } } } diff --git a/test/__snapshots__/index.spec.ts.snap b/test/__snapshots__/index.spec.ts.snap index b021569197..d8ec3d361a 100644 --- a/test/__snapshots__/index.spec.ts.snap +++ b/test/__snapshots__/index.spec.ts.snap @@ -2082,6 +2082,21 @@ Array [ "status": 200, "to": "/.netlify/functions/___netlify-handler", }, + Object { + "from": "/api/enterPreview", + "status": 200, + "to": "/.netlify/functions/api-0", + }, + Object { + "from": "/api/exitPreview", + "status": 200, + "to": "/.netlify/functions/api-0", + }, + Object { + "from": "/api/hello", + "status": 200, + "to": "/.netlify/functions/api-0", + }, Object { "from": "/api/hello-background", "status": 200, @@ -2092,6 +2107,21 @@ Array [ "status": 404, "to": "/404.html", }, + Object { + "from": "/api/revalidate", + "status": 200, + "to": "/.netlify/functions/api-0", + }, + Object { + "from": "/api/shows/:id", + "status": 200, + "to": "/.netlify/functions/api-0", + }, + Object { + "from": "/api/shows/:params/*", + "status": 200, + "to": "/.netlify/functions/api-0", + }, Object { "force": false, "from": "/app-edge", diff --git a/test/helpers/functions.spec.ts b/test/helpers/functions.spec.ts index 965339b8b1..c2083602fb 100644 --- a/test/helpers/functions.spec.ts +++ b/test/helpers/functions.spec.ts @@ -1,19 +1,87 @@ -import { getExtendedApiRouteConfigs } from '../../packages/runtime/src/helpers/functions' +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 getExtendedApiRouteConfigs('.next', process.cwd(), ['js', 'jsx', 'ts', 'tsx']) + const configs = await getApiRouteConfigs('.next', process.cwd(), ['js', 'jsx', 'ts', 'tsx']) // 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_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.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', diff --git a/test/helpers/pack.spec.ts b/test/helpers/pack.spec.ts new file mode 100644 index 0000000000..cf61525878 --- /dev/null +++ b/test/helpers/pack.spec.ts @@ -0,0 +1,16 @@ +import { pack } from '../../packages/runtime/src/helpers/pack' + +it('pack', () => { + expect(pack([], 0)).toEqual([]) + expect(pack([{ value: '10', weight: 10 }], 100)).toEqual([['10']]) + expect( + pack( + [ + { value: '10', weight: 10 }, + { value: '20', weight: 20 }, + { value: '100', weight: 100 }, + ], + 50, + ), + ).toEqual([['100'], ['20', '10']]) +}) diff --git a/test/index.spec.ts b/test/index.spec.ts index 7c10448c7a..ec81feb12a 100644 --- a/test/index.spec.ts +++ b/test/index.spec.ts @@ -84,9 +84,9 @@ beforeEach(async () => { netlifyConfig.redirects = [] netlifyConfig.headers = [] - netlifyConfig.functions[HANDLER_FUNCTION_NAME] && (netlifyConfig.functions[HANDLER_FUNCTION_NAME].included_files = []) - netlifyConfig.functions[ODB_FUNCTION_NAME] && (netlifyConfig.functions[ODB_FUNCTION_NAME].included_files = []) - netlifyConfig.functions['_api_*'] && (netlifyConfig.functions['_api_*'].included_files = []) + for (const func of Object.values(netlifyConfig.functions)) { + func.included_files = [] + } await useFixture('serverless_next_config') }) @@ -465,7 +465,7 @@ describe('onBuild()', () => { it("doesn't exclude sharp if manually included", async () => { await moveNextDist() - const functions = [HANDLER_FUNCTION_NAME, ODB_FUNCTION_NAME, '_api_*'] + const functions = [HANDLER_FUNCTION_NAME, ODB_FUNCTION_NAME] await nextRuntime.onBuild(defaultArgs) @@ -523,6 +523,7 @@ describe('onBuild()', () => { it('generates a file referencing all when publish dir is a subdirectory', async () => { const dir = 'web/.next' await moveNextDist(dir) + netlifyConfig.build.publish = path.resolve(dir) const config = { ...defaultArgs, diff --git a/test/integration/next-package-not-resolvable-in-base-dir.spec.ts b/test/integration/next-package-not-resolvable-in-base-dir.spec.ts index 1c84d113ea..5fd4cc1725 100644 --- a/test/integration/next-package-not-resolvable-in-base-dir.spec.ts +++ b/test/integration/next-package-not-resolvable-in-base-dir.spec.ts @@ -8,6 +8,8 @@ import waitOn from 'wait-on' let destroy = () => {} +const slugify = (str: string) => str.replace('@', '').replace(/\//g, '-') + beforeAll(async () => { const tmpDir = mkdtempSync(join(tmpdir(), `isolated-test-`)) @@ -24,7 +26,7 @@ beforeAll(async () => { // create package tarball const o = await execa(`npm`, [`pack`, `--json`], { cwd: runtimeSrcDir }) - const tgzName = JSON.parse(o.stdout)[0].filename + const tgzName = slugify(JSON.parse(o.stdout)[0].filename) const tgzPath = join(runtimeSrcDir, tgzName) // install runtime from tarball @@ -87,7 +89,5 @@ it(`api route executes correctly`, async () => { const apiResponse = await fetch(`http://localhost:8888/api/hello`) // ensure we got a 200 expect(apiResponse.ok).toBe(true) - // ensure we use ssr handler - expect(apiResponse.headers.get(`x-nf-render-mode`)).toEqual(`ssr`) expect(await apiResponse.json()).toEqual({ name: 'John Doe' }) }) diff --git a/test/templates/server.spec.ts b/test/templates/server.spec.ts index 2f0155e106..a9c9edf65d 100644 --- a/test/templates/server.spec.ts +++ b/test/templates/server.spec.ts @@ -1,3 +1,4 @@ +import { NodeNextRequest, NodeNextResponse } from 'next/dist/server/base-http/node' import { createRequestResponseMocks } from 'next/dist/server/lib/mock-request' import { Options } from 'next/dist/server/next-server' @@ -76,7 +77,7 @@ describe('the netlify next server', () => { const { req: mockReq, res: mockRes } = createRequestResponseMocks({ url: '/getStaticProps/with-revalidate/' }) // @ts-expect-error - Types are incorrect for `MockedResponse` - await requestHandler(mockReq, mockRes) + await requestHandler(new NodeNextRequest(mockReq), new NodeNextResponse(mockRes)) expect(mockedApiFetch).not.toHaveBeenCalled() }) @@ -90,7 +91,7 @@ describe('the netlify next server', () => { headers: { 'x-prerender-revalidate': 'test' }, }) // @ts-expect-error - Types are incorrect for `MockedResponse` - await requestHandler(mockReq, mockRes) + await requestHandler(new NodeNextRequest(mockReq), new NodeNextResponse(mockRes)) expect(mockedApiFetch).toHaveBeenCalledWith( expect.objectContaining({ @@ -110,7 +111,7 @@ describe('the netlify next server', () => { headers: { 'x-prerender-revalidate': 'test' }, }) // @ts-expect-error - Types are incorrect for `MockedResponse` - await requestHandler(mockReq, mockRes) + await requestHandler(new NodeNextRequest(mockReq), new NodeNextResponse(mockRes)) expect(mockedApiFetch).toHaveBeenCalledWith( expect.objectContaining({ @@ -130,7 +131,7 @@ describe('the netlify next server', () => { headers: { 'x-prerender-revalidate': 'test' }, }) // @ts-expect-error - Types are incorrect for `MockedResponse` - await requestHandler(mockReq, mockRes) + await requestHandler(new NodeNextRequest(mockReq), new NodeNextResponse(mockRes)) expect(mockedApiFetch).toHaveBeenCalledWith( expect.objectContaining({ @@ -150,7 +151,7 @@ describe('the netlify next server', () => { headers: { 'x-prerender-revalidate': 'test' }, }) // @ts-expect-error - Types are incorrect for `MockedResponse` - await requestHandler(mockReq, mockRes) + await requestHandler(new NodeNextRequest(mockReq), new NodeNextResponse(mockRes)) expect(mockedApiFetch).toHaveBeenCalledWith( expect.objectContaining({ @@ -171,7 +172,9 @@ describe('the netlify next server', () => { }) // @ts-expect-error - Types are incorrect for `MockedResponse` - await expect(requestHandler(mockReq, mockRes)).rejects.toThrow('not an ISR route') + await expect(requestHandler(new NodeNextRequest(mockReq), new NodeNextResponse(mockRes))).rejects.toThrow( + 'not an ISR route', + ) }) it('throws an error when paths are not found by the API', async () => { @@ -185,7 +188,9 @@ describe('the netlify next server', () => { mockedApiFetch.mockResolvedValueOnce({ code: 500, message: 'Failed to revalidate' }) // @ts-expect-error - Types are incorrect for `MockedResponse` - await expect(requestHandler(mockReq, mockRes)).rejects.toThrow('Failed to revalidate') + await expect(requestHandler(new NodeNextRequest(mockReq), new NodeNextResponse(mockRes))).rejects.toThrow( + 'Failed to revalidate', + ) }) it('throws an error when the revalidate API is unreachable', async () => { @@ -199,6 +204,8 @@ describe('the netlify next server', () => { mockedApiFetch.mockRejectedValueOnce(new Error('Unable to connect')) // @ts-expect-error - Types are incorrect for `MockedResponse` - await expect(requestHandler(mockReq, mockRes)).rejects.toThrow('Unable to connect') + await expect(requestHandler(new NodeNextRequest(mockReq), new NodeNextResponse(mockRes))).rejects.toThrow( + 'Unable to connect', + ) }) })