diff --git a/src/build/next.ts b/src/build/next.ts index 5ae9940..593171d 100644 --- a/src/build/next.ts +++ b/src/build/next.ts @@ -1,27 +1,25 @@ import childProcess from 'node:child_process' import fs from 'fs/promises' import path from 'node:path' +import type { PrerenderManifest, RoutesManifest } from 'next/dist/build' import { type ProjectPackager, type ProjectSettings } from '../common/project' import appRouterRevalidateTemplate from './cache/handlers/appRouterRevalidate' interface BuildOptions { packager: ProjectPackager nextConfigPath: string - s3BucketName: string isAppDir: boolean projectPath: string } interface BuildAppOptions { outputPath: string - s3BucketName: string projectSettings: ProjectSettings } export const OUTPUT_FOLDER = 'serverless-next' -const setNextEnvs = (s3BucketName: string) => { - process.env.STATIC_BUCKET_NAME = s3BucketName +const setNextEnvs = () => { process.env.NEXT_SERVERLESS_DEPLOYING_PHASE = 'true' } @@ -36,9 +34,9 @@ const appendRevalidateApi = async (projectPath: string, isAppDir: boolean): Prom } export const buildNext = async (options: BuildOptions): Promise<() => Promise> => { - const { packager, projectPath, s3BucketName, isAppDir } = options + const { packager, projectPath, isAppDir } = options - setNextEnvs(s3BucketName) + setNextEnvs() const revalidateRoutePath = await appendRevalidateApi(projectPath, isAppDir) childProcess.execSync(packager.buildCommand, { stdio: 'inherit' }) @@ -62,15 +60,36 @@ const copyAssets = async (outputPath: string, appPath: string, appRelativePath: ) } +export const getNextCachedRoutesMatchers = async (outputPath: string, appRelativePath: string): Promise => { + const prerenderManifestJSON = await fs.readFile( + path.join(outputPath, '.next', 'standalone', appRelativePath, '.next', 'prerender-manifest.json'), + 'utf-8' + ) + const routesManifestJSON = await fs.readFile( + path.join(outputPath, '.next', 'standalone', appRelativePath, '.next', 'routes-manifest.json'), + 'utf-8' + ) + + const prerenderManifest = JSON.parse(prerenderManifestJSON) as PrerenderManifest + const routesManifest = JSON.parse(routesManifestJSON) as RoutesManifest + + return [...routesManifest.dynamicRoutes, ...routesManifest.staticRoutes].reduce((prev, route) => { + if (prerenderManifest.routes?.[route.page] || prerenderManifest.dynamicRoutes?.[route.page]) { + prev.push(route.regex) + } + + return prev + }, [] as string[]) +} + export const buildApp = async (options: BuildAppOptions) => { - const { projectSettings, outputPath, s3BucketName } = options + const { projectSettings, outputPath } = options const { packager, nextConfigPath, projectPath, isAppDir, root, isMonorepo } = projectSettings const cleanNextApp = await buildNext({ packager, nextConfigPath, - s3BucketName, isAppDir, projectPath }) @@ -78,6 +97,7 @@ export const buildApp = async (options: BuildAppOptions) => { const appRelativePath = isMonorepo ? path.relative(root, projectPath) : '' await copyAssets(outputPath, projectPath, appRelativePath) + const nextCachedRoutesMatchers = await getNextCachedRoutesMatchers(outputPath, appRelativePath) - return cleanNextApp + return { cleanNextApp, nextCachedRoutesMatchers } } diff --git a/src/build/withNextDeploy.ts b/src/build/withNextDeploy.ts index 2c0eb50..5850265 100644 --- a/src/build/withNextDeploy.ts +++ b/src/build/withNextDeploy.ts @@ -10,8 +10,7 @@ export const withNextDeploy = async (nextConfig: NextConfig): Promise = { @@ -26,7 +27,15 @@ export class OriginRequestLambdaEdge extends Construct { public readonly lambdaEdge: cloudfront.experimental.EdgeFunction constructor(scope: Construct, id: string, props: OriginRequestLambdaEdgeProps) { - const { bucketName, bucketRegion, renderServerDomain, nodejs, buildOutputPath, cacheConfig } = props + const { + bucketName, + bucketRegion, + renderServerDomain, + nodejs, + buildOutputPath, + cacheConfig, + nextCachedRoutesMatchers + } = props super(scope, id) const nodeJSEnvironment = NodeJSEnvironmentMapping[nodejs ?? ''] ?? NodeJSEnvironmentMapping['20'] @@ -37,7 +46,8 @@ export class OriginRequestLambdaEdge extends Construct { 'process.env.S3_BUCKET': JSON.stringify(bucketName), 'process.env.S3_BUCKET_REGION': JSON.stringify(bucketRegion ?? ''), 'process.env.EB_APP_URL': JSON.stringify(renderServerDomain), - 'process.env.CACHE_CONFIG': JSON.stringify(cacheConfig) + 'process.env.CACHE_CONFIG': JSON.stringify(cacheConfig), + 'process.env.NEXT_CACHED_ROUTES_MATCHERS': JSON.stringify(nextCachedRoutesMatchers ?? []) } }) diff --git a/src/cdk/stacks/NextCloudfrontStack.ts b/src/cdk/stacks/NextCloudfrontStack.ts index f8431d8..c401da7 100644 --- a/src/cdk/stacks/NextCloudfrontStack.ts +++ b/src/cdk/stacks/NextCloudfrontStack.ts @@ -20,6 +20,7 @@ export interface NextCloudfrontStackProps extends StackProps { imageTTL?: number redirects?: NextRedirects trailingSlash?: boolean + nextCachedRoutesMatchers: string[] } export class NextCloudfrontStack extends Stack { @@ -42,7 +43,8 @@ export class NextCloudfrontStack extends Stack { deployConfig, imageTTL, redirects, - trailingSlash = false + trailingSlash = false, + nextCachedRoutesMatchers } = props this.originRequestLambdaEdge = new OriginRequestLambdaEdge(this, `${id}-OriginRequestLambdaEdge`, { @@ -51,7 +53,8 @@ export class NextCloudfrontStack extends Stack { renderServerDomain, buildOutputPath, cacheConfig: deployConfig.cache, - bucketRegion: region + bucketRegion: region, + nextCachedRoutesMatchers }) this.originResponseLambdaEdge = new OriginResponseLambdaEdge(this, `${id}-OriginResponseLambdaEdge`, { diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts index 0a4d052..40e4085 100644 --- a/src/commands/deploy.ts +++ b/src/commands/deploy.ts @@ -111,6 +111,14 @@ export const deploy = async (config: DeployConfig) => { } const siteNameLowerCased = siteName.toLowerCase() + // Build and zip app. + const { cleanNextApp: cleanAppBuild, nextCachedRoutesMatchers } = await buildApp({ + projectSettings, + outputPath + }) + + cleanNextApp = cleanAppBuild + const nextRenderServerStack = new AppStack( `${siteNameLowerCased}-server`, NextRenderServerStack, @@ -152,6 +160,7 @@ export const deploy = async (config: DeployConfig) => { imageTTL: nextConfig.imageTTL, trailingSlash: nextConfig.trailingSlash, redirects: nextRedirects, + nextCachedRoutesMatchers, env: { region: AWS_EDGE_REGION // required since Edge can be deployed only here. } @@ -159,13 +168,6 @@ export const deploy = async (config: DeployConfig) => { ) const nextCloudfrontStackOutput = await nextCloudfrontStack.deployStack() - // Build and zip app. - cleanNextApp = await buildApp({ - projectSettings, - outputPath, - s3BucketName: nextRenderServerStackOutput.StaticBucketName - }) - const now = Date.now() const archivedFolderName = `${OUTPUT_FOLDER}-server-v${now}.zip` const buildOutputPathArchived = path.join(outputPath, archivedFolderName) diff --git a/src/lambdas/originRequest.ts b/src/lambdas/originRequest.ts index 9380c47..067ff0d 100644 --- a/src/lambdas/originRequest.ts +++ b/src/lambdas/originRequest.ts @@ -3,8 +3,6 @@ import type { CloudFrontRequestEvent, CloudFrontRequestCallback, CloudFrontReque import crypto from 'node:crypto' import { CacheConfig } from '../types' import { - makeHTTPRequest, - convertCloudFrontHeaders, transformQueryToObject, transformCookiesToObject, getCurrentDeviceType, @@ -86,14 +84,15 @@ export const handler = async ( const request = event.Records[0].cf.request const s3Bucket = process.env.S3_BUCKET! const cacheConfig = process.env.CACHE_CONFIG as CacheConfig + const nextCachedRoutesMatchers = process.env.NEXT_CACHED_ROUTES_MATCHERS as unknown as string[] const { s3Key } = getS3ObjectPath(request, cacheConfig) const ebAppUrl = process.env.EB_APP_URL! - const originalUri = request.uri - const queryParams = request.querystring ? `?${request.querystring}` : '' + + const isCachedRoute = nextCachedRoutesMatchers.some((matcher) => RegExp(matcher).test(request.uri)) try { - // Check if file exists in S3 - const isFileExists = await checkFileExistsInS3(s3Bucket, s3Key) + // Check if file exists in S3 when route accepts caching. + const isFileExists = isCachedRoute ? await checkFileExistsInS3(s3Bucket, s3Key) : false if (isFileExists) { // Modify s3 path request @@ -102,20 +101,21 @@ export const handler = async ( // If file exists, allow the request to proceed to S3 callback(null, request) } else { - const options = { - hostname: ebAppUrl, - path: `${originalUri}${queryParams}`, - method: request.method, - headers: convertCloudFrontHeaders(request.headers) + request.origin = { + custom: { + domainName: ebAppUrl, + port: 80, + protocol: 'http', + path: '', + keepaliveTimeout: 5, + readTimeout: 30, + customHeaders: {}, + sslProtocols: ['TLSv1.2'] + } } - const { body, statusCode, statusMessage } = await makeHTTPRequest(options) - - callback(null, { - status: statusCode?.toString() || '500', - statusDescription: statusMessage || 'Internal Server Error', - body - }) + request.headers['host'] = [{ key: 'host', value: ebAppUrl }] + callback(null, request) } } catch (_e) { const error = _e as Error diff --git a/src/lambdas/utils/request.ts b/src/lambdas/utils/request.ts index 4b384c0..c413197 100644 --- a/src/lambdas/utils/request.ts +++ b/src/lambdas/utils/request.ts @@ -1,46 +1,7 @@ -import http, { type RequestOptions } from 'http' +import { type RequestOptions } from 'http' import type { CloudFrontRequest } from 'aws-lambda' import { HEADER_DEVICE_TYPE } from '../../constants' -/** - * Makes an HTTP request with the given options and returns a promise with the response - * @param {RequestOptions & { body?: string }} options - HTTP request options including optional body - * @returns {Promise<{body: string, statusCode?: number, statusMessage?: string}>} Response object containing body, status code and message - */ -export async function makeHTTPRequest(options: RequestOptions & { body?: string }): Promise<{ - body: string - statusCode?: number - statusMessage?: string -}> { - return new Promise((resolve, reject) => { - const req = http.request(options, (res) => { - let data = '' - - res.on('data', (chunk) => { - data += chunk - }) - - res.on('end', () => { - resolve({ - body: data, - statusCode: res.statusCode, - statusMessage: res.statusMessage - }) - }) - }) - - if (options.body) { - req.write(options.body) - } - - req.on('error', (e) => { - reject(e) - }) - - req.end() - }) -} - /** * Converts CloudFront headers to standard HTTP request headers * @param {CloudFrontRequest['headers'] | undefined} cloudfrontHeaders - Headers from CloudFront request