Skip to content

feat: Use none bundler for SSR Routes #2084

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 11 commits into from
Jul 3, 2023
1 change: 1 addition & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ module.exports = {
// https://github.com/typescript-eslint/typescript-eslint/issues/2483
'no-shadow': 'off',
'@typescript-eslint/no-shadow': 'error',
'import/max-dependencies': 'off',
},
},
{
Expand Down
4 changes: 4 additions & 0 deletions demos/canary/netlify.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ 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"
NEXT_BUNDLE_BASED_ON_NFT_FILES = "true"

[[plugins]]
package = "@netlify/plugin-nextjs"

Expand Down
2 changes: 2 additions & 0 deletions demos/default/netlify.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ CYPRESS_CACHE_FOLDER = "../node_modules/.CypressBinary"
# set TERM variable for terminal output
TERM = "xterm"
NODE_VERSION = "16.15.1"
NEXT_SPLIT_API_ROUTES = "true"
NEXT_BUNDLE_BASED_ON_NFT_FILES = "true"

[[headers]]
for = "/_next/image/*"
Expand Down
4 changes: 4 additions & 0 deletions demos/middleware/netlify.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ 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"
NEXT_BUNDLE_BASED_ON_NFT_FILES = "true"

[[plugins]]
package = "@netlify/plugin-nextjs"

Expand Down
1 change: 1 addition & 0 deletions demos/nx-next-monorepo-demo/netlify.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ ignore = "if [ $CACHED_COMMIT_REF == $COMMIT_REF ]; then (exit 1); else git diff

[build.environment]
NEXT_SPLIT_API_ROUTES = "true"
NEXT_BUNDLE_BASED_ON_NFT_FILES = "true"

[dev]
command = "npm run start"
Expand Down
1 change: 1 addition & 0 deletions demos/static-root/netlify.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ ignore = "if [ $CACHED_COMMIT_REF == $COMMIT_REF ]; then (exit 1); else git diff

[build.environment]
NEXT_SPLIT_API_ROUTES = "true"
NEXT_BUNDLE_BASED_ON_NFT_FILES = "true"

[[plugins]]
package = "@netlify/plugin-nextjs"
Expand Down
28 changes: 18 additions & 10 deletions packages/runtime/src/helpers/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +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 { APILambda, SSRLambda } from './functions'
import type { RoutesManifest } from './types'
import { escapeStringRegexp } from './utils'

Expand Down Expand Up @@ -107,12 +107,14 @@ export const configureHandlerFunctions = async ({
publish,
ignore = [],
apiLambdas,
ssrLambdas,
splitApiRoutes,
}: {
netlifyConfig: NetlifyConfig
publish: string
ignore: Array<string>
apiLambdas: APILambda[]
ssrLambdas: SSRLambda[]
splitApiRoutes: boolean
}) => {
const config = await getRequiredServerFiles(publish)
Expand Down Expand Up @@ -170,17 +172,23 @@ export const configureHandlerFunctions = async ({
})
}

configureFunction(HANDLER_FUNCTION_NAME)
configureFunction(ODB_FUNCTION_NAME)
const configureLambda = (lambda: APILambda) => {
const { functionName, includedFiles } = lambda
netlifyConfig.functions[functionName] ||= { included_files: [] }
netlifyConfig.functions[functionName].node_bundler = 'none'
netlifyConfig.functions[functionName].included_files ||= []
netlifyConfig.functions[functionName].included_files.push(...includedFiles.map(escapeGlob))
}

if (ssrLambdas.length === 0) {
configureFunction(HANDLER_FUNCTION_NAME)
configureFunction(ODB_FUNCTION_NAME)
} else {
ssrLambdas.forEach(configureLambda)
}

if (splitApiRoutes) {
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.map(escapeGlob))
}
apiLambdas.forEach(configureLambda)
} else {
configureFunction('_api_*')
}
Expand Down
7 changes: 7 additions & 0 deletions packages/runtime/src/helpers/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,10 @@ export const splitApiRoutes = (featureFlags: Record<string, unknown>, publish: s

return isEnabled
}

export const bundleBasedOnNftFiles = (featureFlags: Record<string, unknown>): boolean => {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Note to self: We should probably make splitApiRoute another activation guard for this.

const isEnabled =
destr(process.env.NEXT_BUNDLE_BASED_ON_NFT_FILES) ?? featureFlags.next_bundle_based_on_nft_files ?? false

return isEnabled
}
127 changes: 120 additions & 7 deletions packages/runtime/src/helpers/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import bridgeFile from '@vercel/node-bridge'
import chalk from 'chalk'
import destr from 'destr'
import { copyFile, ensureDir, existsSync, readJSON, writeFile, writeJSON, stat } from 'fs-extra'
import { PrerenderManifest } from 'next/dist/build'
import type { ImageConfigComplete, RemotePattern } from 'next/dist/shared/lib/image-config'
import { outdent } from 'outdent'
import { join, relative, resolve, dirname } from 'pathe'
import { join, relative, resolve, dirname, basename, extname } from 'pathe'
import glob from 'tiny-glob'

import {
Expand All @@ -32,27 +33,35 @@ import { pack } from './pack'
import { ApiRouteType } from './types'
import { getFunctionNameForPage } from './utils'

export interface ApiRouteConfig {
export interface RouteConfig {
functionName: string
functionTitle?: string
route: string
config: ApiConfig
compiled: string
includedFiles: string[]
}

export interface APILambda {
export interface ApiRouteConfig extends RouteConfig {
config: ApiConfig
}

export interface SSRLambda {
functionName: string
functionTitle: string
routes: ApiRouteConfig[]
routes: RouteConfig[]
includedFiles: string[]
}

export interface APILambda extends SSRLambda {
routes: ApiRouteConfig[]
type?: ApiRouteType
}

export const generateFunctions = async (
{ FUNCTIONS_SRC = DEFAULT_FUNCTIONS_SRC, INTERNAL_FUNCTIONS_SRC, PUBLISH_DIR }: NetlifyPluginConstants,
appDir: string,
apiLambdas: APILambda[],
ssrLambdas: SSRLambda[],
): Promise<void> => {
const publish = resolve(PUBLISH_DIR)
const functionsDir = resolve(INTERNAL_FUNCTIONS_SRC || FUNCTIONS_SRC)
Expand Down Expand Up @@ -144,6 +153,12 @@ export const generateFunctions = async (
join(functionsDir, functionName, 'handlerUtils.js'),
)
await writeFunctionConfiguration({ functionName, functionTitle, functionsDir })

const nfInternalFiles = await glob(join(functionsDir, functionName, '**'))
const lambda = ssrLambdas.find((l) => l.functionName === functionName)
if (lambda) {
lambda.includedFiles.push(...nfInternalFiles)
}
}

await writeHandler(HANDLER_FUNCTION_NAME, HANDLER_FUNCTION_TITLE, false)
Expand Down Expand Up @@ -295,13 +310,17 @@ export const traceNPMPackage = async (packageName: string, publish: string) => {
}
}

export const getAPIPRouteCommonDependencies = async (publish: string) => {
export const getCommonDependencies = async (publish: string) => {
const deps = await Promise.all([
traceRequiredServerFiles(publish),
traceNextServer(publish),

// used by our own bridge.js
traceNPMPackage('follow-redirects', publish),

// using package.json because otherwise, we'd find some /dist/... path
traceNPMPackage('@netlify/functions/package.json', publish),
traceNPMPackage('is-promise', publish),
])

return deps.flat(1)
Expand Down Expand Up @@ -329,12 +348,106 @@ const getBundleWeight = async (patterns: string[]) => {
return sum(sizes.flat(1))
}

const changeExtension = (file: string, extension: string) => {
const base = basename(file, extname(file))
return join(dirname(file), base + extension)
}

const getSSRDependencies = async (publish: string): Promise<string[]> => {
const prerenderManifest: PrerenderManifest = await readJSON(join(publish, 'prerender-manifest.json'))

return [
...Object.entries(prerenderManifest.routes).flatMap(([route, ssgRoute]) => {
if (ssgRoute.initialRevalidateSeconds === false) {
return []
}

if (ssgRoute.dataRoute.endsWith('.rsc')) {
return [
join(publish, 'server', 'app', ssgRoute.dataRoute),
join(publish, 'server', 'app', changeExtension(ssgRoute.dataRoute, '.html')),
]
}

const trimmedPath = route === '/' ? 'index' : route.slice(1)
return [
join(publish, 'server', 'pages', `${trimmedPath}.html`),
join(publish, 'server', 'pages', `${trimmedPath}.json`),
]
}),
join(publish, '**', '*.html'),
join(publish, 'static-manifest.json'),
]
}

export const getSSRLambdas = async (publish: string): Promise<SSRLambda[]> => {
const commonDependencies = await getCommonDependencies(publish)
const ssrRoutes = await getSSRRoutes(publish)

// TODO: for now, they're the same - but we should separate them
const nonOdbRoutes = ssrRoutes
const odbRoutes = ssrRoutes

const ssrDependencies = await getSSRDependencies(publish)

return [
{
functionName: HANDLER_FUNCTION_NAME,
functionTitle: HANDLER_FUNCTION_TITLE,
includedFiles: [
...commonDependencies,
...ssrDependencies,
...nonOdbRoutes.flatMap((route) => route.includedFiles),
],
routes: nonOdbRoutes,
},
{
functionName: ODB_FUNCTION_NAME,
functionTitle: ODB_FUNCTION_TITLE,
includedFiles: [...commonDependencies, ...ssrDependencies, ...odbRoutes.flatMap((route) => route.includedFiles)],
routes: odbRoutes,
},
]
}

const getSSRRoutes = async (publish: string): Promise<RouteConfig[]> => {
const pageManifest = (await readJSON(join(publish, 'server', 'pages-manifest.json'))) as Record<string, string>
const pageManifestRoutes = Object.entries(pageManifest).filter(
([page, compiled]) => !page.startsWith('/api/') && !compiled.endsWith('.html'),
)

const appPathsManifest: Record<string, string> = await readJSON(
join(publish, 'server', 'app-paths-manifest.json'),
).catch(() => ({}))
const appRoutes = Object.entries(appPathsManifest)

const routes = [...pageManifestRoutes, ...appRoutes]

return await Promise.all(
routes.map(async ([route, compiled]) => {
const functionName = getFunctionNameForPage(route)

const compiledPath = join(publish, 'server', compiled)

const routeDependencies = await getDependenciesOfFile(compiledPath)
const includedFiles = [compiledPath, ...routeDependencies]

return {
functionName,
route,
compiled,
includedFiles,
}
}),
)
}

export const getAPILambdas = async (
publish: string,
baseDir: string,
pageExtensions: string[],
): Promise<APILambda[]> => {
const commonDependencies = await getAPIPRouteCommonDependencies(publish)
const commonDependencies = await getCommonDependencies(publish)

const threshold = LAMBDA_WARNING_SIZE - (await getBundleWeight(commonDependencies))

Expand Down
9 changes: 7 additions & 2 deletions packages/runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
import { onPreDev } from './helpers/dev'
import { writeEdgeFunctions, loadMiddlewareManifest, cleanupEdgeFunctions } from './helpers/edge'
import { moveStaticPages, movePublicFiles, removeMetadataFiles } from './helpers/files'
import { splitApiRoutes } from './helpers/flags'
import { bundleBasedOnNftFiles, splitApiRoutes } from './helpers/flags'
import {
generateFunctions,
setupImageFunction,
Expand All @@ -28,6 +28,7 @@ import {
packSingleFunction,
getExtendedApiRouteConfigs,
APILambda,
getSSRLambdas,
} from './helpers/functions'
import { generateRedirects, generateStaticRedirects } from './helpers/redirects'
import { shouldSkip, isNextAuthInstalled, getCustomImageResponseHeaders, getRemotePatterns } from './helpers/utils'
Expand All @@ -50,6 +51,7 @@ const plugin: NetlifyPlugin = {
cache,
},
}) {
console.log('using local version')
const { publish } = netlifyConfig.build
if (shouldSkip()) {
await restoreCache({ cache, publish })
Expand Down Expand Up @@ -171,14 +173,17 @@ const plugin: NetlifyPlugin = {
extendedRoutes.map(packSingleFunction),
)

await generateFunctions(constants, appDir, apiLambdas)
const ssrLambdas = bundleBasedOnNftFiles(featureFlags) ? await getSSRLambdas(publish) : []

await generateFunctions(constants, appDir, apiLambdas, ssrLambdas)
await generatePagesResolver(constants)

await configureHandlerFunctions({
netlifyConfig,
ignore,
publish: relative(process.cwd(), publish),
apiLambdas,
ssrLambdas,
splitApiRoutes: splitApiRoutes(featureFlags, publish),
})

Expand Down
2 changes: 2 additions & 0 deletions packages/runtime/src/templates/getHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,8 @@ export const getHandler = ({
throw new Error('Could not find Next.js server')
}

process.env.NODE_ENV = 'production';
Copy link
Contributor

Choose a reason for hiding this comment

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

What is the reason for this? Just wondering because this will also be the case when running netlify dev.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We're filtering out .development.js, to reduce bundle size:

https://github.com/netlify/next-runtime/blob/main/packages/runtime/src/helpers/functions.ts#L274

(This needs to be done manually for whatever reason)

react-dom is an example for where this helps: https://www.npmjs.com/package/react-dom?activeTab=code

By setting node_env to production, we ensure these files are never required.

Just wondering because this will also be the case when running netlify dev.

AFAIK, we're directly proxying next dev there - so this runtime wouldn't run.

Copy link
Contributor

Choose a reason for hiding this comment

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

Ah, I see. Thanks, that makes sense. And yes, you're absolutely right about proxying next dev - i was having a Friday moment and was mistakenly thinking about netlify serve!


const { Server } = require("http");
const { promises } = require("fs");
// We copy the file here rather than requiring from the node module
Expand Down