import nodeFileTrace, { NodeFileTraceReasons } from '@zeit/node-file-trace'; import execa from 'execa'; import fse from 'fs-extra'; import path, { join } from 'path'; import { pathToRegexp } from 'path-to-regexp'; import getAllFiles from './lib/getAllFilesInDirectory'; import { getSortedRoutes } from './lib/sortedRoutes'; import { BuildOptions, OriginRequestDefaultHandlerManifest, OriginRequestApiHandlerManifest, } from '../types'; import expressifyDynamicRoute from './lib/expressifyDynamicRoute'; import createServerlessConfig from './lib/createServerlessConfig'; export const DEFAULT_LAMBDA_CODE_DIR = 'default-lambda'; export const API_LAMBDA_CODE_DIR = 'api-lambda'; const pathToPosix = (path: string): string => path.replace(/\\/g, '/'); const normalizeNodeModules = (path: string): string => path.substring(path.indexOf('node_modules')); // Identify /[param]/ in route string const isDynamicRoute = (route: string): boolean => /\/\[[^\/]+?\](?=\/|$)/.test(route); const pathToRegexStr = (path: string): string => pathToRegexp(path) .toString() .replace(/\/(.*)\/\i/, '$1'); const defaultBuildOptions = { args: [], cwd: process.cwd(), cmd: './node_modules/.bin/next', }; class Builder { nextConfigDir: string; dotNextDir: string; serverlessDir: string; outputDir: string; buildOptions: BuildOptions = defaultBuildOptions; constructor(nextConfigDir: string, outputDir: string, buildOptions?: BuildOptions) { this.nextConfigDir = path.resolve(nextConfigDir); this.dotNextDir = path.join(this.nextConfigDir, '.next'); this.serverlessDir = path.join(this.dotNextDir, 'serverless'); this.outputDir = outputDir; if (buildOptions) { this.buildOptions = buildOptions; } } async readPublicFiles(): Promise { const dirExists = await fse.pathExists(join(this.nextConfigDir, 'public')); if (dirExists) { return getAllFiles(join(this.nextConfigDir, 'public')) .map((e) => e.replace(this.nextConfigDir, '')) .map((e) => e.split(path.sep).slice(2).join('/')); } else { return []; } } async readPagesManifest(): Promise<{ [key: string]: string }> { const path = join(this.serverlessDir, 'pages-manifest.json'); const hasServerlessPageManifest = await fse.pathExists(path); if (!hasServerlessPageManifest) { return Promise.reject( "pages-manifest not found. Check if `next.config.js` target is set to 'serverless'" ); } const pagesManifest = await fse.readJSON(path); const pagesManifestWithoutDynamicRoutes = Object.keys(pagesManifest).reduce( (acc: { [key: string]: string }, route: string) => { if (isDynamicRoute(route)) { return acc; } acc[route] = pagesManifest[route]; return acc; }, {} ); const dynamicRoutedPages = Object.keys(pagesManifest).filter(isDynamicRoute); const sortedDynamicRoutedPages = getSortedRoutes(dynamicRoutedPages); const sortedPagesManifest = pagesManifestWithoutDynamicRoutes; sortedDynamicRoutedPages.forEach((route) => { sortedPagesManifest[route] = pagesManifest[route]; }); return sortedPagesManifest; } copyLambdaHandlerDependencies( fileList: string[], reasons: NodeFileTraceReasons, handlerDirectory: string ): Promise[] { return fileList .filter((file) => { // exclude "initial" files from lambda artifact. These are just the pages themselves // which are copied over separately return !reasons[file] || reasons[file].type !== 'initial'; }) .map((filePath: string) => { const resolvedFilePath = path.resolve(filePath); const dst = normalizeNodeModules(path.relative(this.serverlessDir, resolvedFilePath)); return fse.copy(resolvedFilePath, join(this.outputDir, handlerDirectory, dst)); }); } async buildDefaultLambda(buildManifest: OriginRequestDefaultHandlerManifest): Promise { const ignoreAppAndDocumentPages = (page: string): boolean => { const basename = path.basename(page); return basename !== '_app.js' && basename !== '_document.js'; }; const allSsrPages = [ ...Object.values(buildManifest.pages.ssr.nonDynamic), ...Object.values(buildManifest.pages.ssr.dynamic).map((entry) => entry.file), ].filter(ignoreAppAndDocumentPages); const ssrPages = Object.values(allSsrPages).map((pageFile) => path.join(this.serverlessDir, pageFile) ); const { fileList, reasons } = await nodeFileTrace(ssrPages, { base: process.cwd(), }); const copyTraces = this.copyLambdaHandlerDependencies( fileList, reasons, DEFAULT_LAMBDA_CODE_DIR ); return Promise.all([ ...copyTraces, fse.copy( require.resolve('@next-deploy/aws-lambda-builder/dist/default-handler.js'), join(this.outputDir, DEFAULT_LAMBDA_CODE_DIR, 'index.js') ), fse.copy( require.resolve('@next-deploy/aws-lambda-builder/dist/compat.js'), join(this.outputDir, DEFAULT_LAMBDA_CODE_DIR, 'compat.js') ), fse.writeJson(join(this.outputDir, DEFAULT_LAMBDA_CODE_DIR, 'manifest.json'), buildManifest), fse.copy( join(this.serverlessDir, 'pages'), join(this.outputDir, DEFAULT_LAMBDA_CODE_DIR, 'pages'), { filter: (file: string) => { const isNotPrerenderedHTMLPage = path.extname(file) !== '.html'; const isNotStaticPropsJSONFile = path.extname(file) !== '.json'; const isNotApiPage = pathToPosix(file).indexOf('pages/api') === -1; return isNotApiPage && isNotPrerenderedHTMLPage && isNotStaticPropsJSONFile; }, } ), fse.copy( join(this.dotNextDir, 'prerender-manifest.json'), join(this.outputDir, DEFAULT_LAMBDA_CODE_DIR, 'prerender-manifest.json') ), ]); } async buildApiLambda(apiBuildManifest: OriginRequestApiHandlerManifest): Promise { const allApiPages = [ ...Object.values(apiBuildManifest.apis.nonDynamic), ...Object.values(apiBuildManifest.apis.dynamic).map((entry) => entry.file), ]; const apiPages = Object.values(allApiPages).map((pageFile) => path.join(this.serverlessDir, pageFile) ); const { fileList, reasons } = await nodeFileTrace(apiPages, { base: process.cwd(), }); const copyTraces = this.copyLambdaHandlerDependencies(fileList, reasons, API_LAMBDA_CODE_DIR); return Promise.all([ ...copyTraces, fse.copy( require.resolve('@next-deploy/aws-lambda-builder/dist/api-handler.js'), join(this.outputDir, API_LAMBDA_CODE_DIR, 'index.js') ), fse.copy( require.resolve('@next-deploy/aws-lambda-builder/dist/compat.js'), join(this.outputDir, API_LAMBDA_CODE_DIR, 'compat.js') ), fse.copy( join(this.serverlessDir, 'pages/api'), join(this.outputDir, API_LAMBDA_CODE_DIR, 'pages/api') ), fse.writeJson(join(this.outputDir, API_LAMBDA_CODE_DIR, 'manifest.json'), apiBuildManifest), ]); } async prepareBuildManifests(): Promise<{ defaultBuildManifest: OriginRequestDefaultHandlerManifest; apiBuildManifest: OriginRequestApiHandlerManifest; }> { const pagesManifest = await this.readPagesManifest(); const buildId = await fse.readFile(path.join(this.dotNextDir, 'BUILD_ID'), 'utf-8'); const defaultBuildManifest: OriginRequestDefaultHandlerManifest = { buildId, pages: { ssr: { dynamic: {}, nonDynamic: {}, }, html: { dynamic: {}, nonDynamic: {}, }, }, publicFiles: {}, }; const apiBuildManifest: OriginRequestApiHandlerManifest = { apis: { dynamic: {}, nonDynamic: {}, }, }; const ssrPages = defaultBuildManifest.pages.ssr; const htmlPages = defaultBuildManifest.pages.html; const apiPages = apiBuildManifest.apis; const isHtmlPage = (path: string): boolean => path.endsWith('.html'); const isApiPage = (path: string): boolean => path.startsWith('pages/api'); Object.entries(pagesManifest).forEach(([route, pageFile]) => { const dynamicRoute = isDynamicRoute(route); const expressRoute = dynamicRoute ? expressifyDynamicRoute(route) : null; if (isHtmlPage(pageFile)) { if (dynamicRoute) { const route = expressRoute as string; htmlPages.dynamic[route] = { file: pageFile, regex: pathToRegexStr(route), }; } else { htmlPages.nonDynamic[route] = pageFile; } } else if (isApiPage(pageFile)) { if (dynamicRoute) { const route = expressRoute as string; apiPages.dynamic[route] = { file: pageFile, regex: pathToRegexStr(route), }; } else { apiPages.nonDynamic[route] = pageFile; } } else if (dynamicRoute) { const route = expressRoute as string; ssrPages.dynamic[route] = { file: pageFile, regex: pathToRegexStr(route), }; } else { ssrPages.nonDynamic[route] = pageFile; } }); const publicFiles = await this.readPublicFiles(); publicFiles.forEach((pf) => { defaultBuildManifest.publicFiles['/' + pf] = pf; }); return { defaultBuildManifest, apiBuildManifest, }; } async cleanupDotNext(): Promise { const exists = await fse.pathExists(this.dotNextDir); if (exists) { const fileItems = await fse.readdir(this.dotNextDir); await Promise.all( fileItems .filter( (fileItem) => fileItem !== 'cache' // avoid deleting the cache folder as that would lead to slow builds! ) .map((fileItem) => fse.remove(join(this.dotNextDir, fileItem))) ); } } async build(debugMode: boolean): Promise { const { cmd, args, cwd } = Object.assign(defaultBuildOptions, this.buildOptions); await this.cleanupDotNext(); await fse.emptyDir(join(this.outputDir, DEFAULT_LAMBDA_CODE_DIR)); await fse.emptyDir(join(this.outputDir, API_LAMBDA_CODE_DIR)); const { restoreUserConfig } = await createServerlessConfig(cwd, path.join(this.nextConfigDir)); try { const subprocess = execa(cmd, args, { cwd, }); if (debugMode) { // @ts-ignore subprocess.stdout.pipe(process.stdout); } await subprocess; } finally { await restoreUserConfig(); } const { defaultBuildManifest, apiBuildManifest } = await this.prepareBuildManifests(); await this.buildDefaultLambda(defaultBuildManifest); const hasAPIPages = Object.keys(apiBuildManifest.apis.nonDynamic).length > 0 || Object.keys(apiBuildManifest.apis.dynamic).length > 0; if (hasAPIPages) { await this.buildApiLambda(apiBuildManifest); } } } export default Builder;