Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 29 additions & 9 deletions src/build/next.ts
Original file line number Diff line number Diff line change
@@ -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'
}

Expand All @@ -36,9 +34,9 @@ const appendRevalidateApi = async (projectPath: string, isAppDir: boolean): Prom
}

export const buildNext = async (options: BuildOptions): Promise<() => Promise<void>> => {
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' })

Expand All @@ -62,22 +60,44 @@ const copyAssets = async (outputPath: string, appPath: string, appRelativePath:
)
}

export const getNextCachedRoutesMatchers = async (outputPath: string, appRelativePath: string): Promise<string[]> => {
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
})

const appRelativePath = isMonorepo ? path.relative(root, projectPath) : ''

await copyAssets(outputPath, projectPath, appRelativePath)
const nextCachedRoutesMatchers = await getNextCachedRoutesMatchers(outputPath, appRelativePath)

return cleanNextApp
return { cleanNextApp, nextCachedRoutesMatchers }
}
3 changes: 1 addition & 2 deletions src/build/withNextDeploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,7 @@ export const withNextDeploy = async (nextConfig: NextConfig): Promise<NextConfig
output: 'standalone',
serverRuntimeConfig: {
...nextConfig.serverRuntimeConfig,
nextServerlessCacheConfig: cacheConfig,
staticBucketName: process.env.STATIC_BUCKET_NAME
nextServerlessCacheConfig: cacheConfig
},
cacheHandler: require.resolve(path.join('..', 'cacheHandler', 'index.js'))
}
Expand Down
15 changes: 7 additions & 8 deletions src/cacheHandler/index.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,17 @@
import { Cache } from '@dbbs/next-cache-handler-core'
import getConfig from 'next/config'
import { CacheConfig } from '../types'
import { DeployConfig } from '../types'
import { S3Cache } from './strategy/s3'

const { serverRuntimeConfig } = getConfig() || {}
const config: CacheConfig | undefined = serverRuntimeConfig?.nextServerlessCacheConfig
const staticBucketName = serverRuntimeConfig?.staticBucketName || ''
const config: DeployConfig | undefined = serverRuntimeConfig?.nextServerlessCacheConfig

Cache.setConfig({
cacheCookies: config?.cacheCookies ?? [],
cacheQueries: config?.cacheQueries ?? [],
noCacheMatchers: config?.noCacheRoutes ?? [],
enableDeviceSplit: config?.enableDeviceSplit,
cache: new S3Cache(staticBucketName)
cacheCookies: config?.cache.cacheCookies ?? [],
cacheQueries: config?.cache.cacheQueries ?? [],
noCacheMatchers: config?.cache.noCacheRoutes ?? [],
enableDeviceSplit: config?.cache.enableDeviceSplit,
cache: new S3Cache(process.env.STATIC_BUCKET_NAME!)
})

export default Cache
2 changes: 1 addition & 1 deletion src/cdk/constructs/CloudFrontDistribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const OneMonthCache = Duration.days(30)
const NoCache = Duration.seconds(0)

const defaultNextQueries = ['_rsc']
const defaultNextHeaders = ['Cache-Control']
const defaultNextHeaders = ['Cache-Control', 'Next-Router-State-Tree', 'Next-Url', 'Rsc', 'Next-Router-Prefetch']
const imageQueries = ['w', 'h', 'url', 'q']
export class CloudFrontDistribution extends Construct {
public readonly cf: cloudfront.Distribution
Expand Down
14 changes: 12 additions & 2 deletions src/cdk/constructs/OriginRequestLambdaEdge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ interface OriginRequestLambdaEdgeProps extends cdk.StackProps {
nodejs?: string
cacheConfig: CacheConfig
bucketRegion?: string
nextCachedRoutesMatchers: string[]
}

const NodeJSEnvironmentMapping: Record<string, lambda.Runtime> = {
Expand All @@ -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']
Expand All @@ -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 ?? [])
}
})

Expand Down
7 changes: 5 additions & 2 deletions src/cdk/stacks/NextCloudfrontStack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export interface NextCloudfrontStackProps extends StackProps {
imageTTL?: number
redirects?: NextRedirects
trailingSlash?: boolean
nextCachedRoutesMatchers: string[]
}

export class NextCloudfrontStack extends Stack {
Expand All @@ -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`, {
Expand All @@ -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`, {
Expand Down
16 changes: 9 additions & 7 deletions src/commands/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<NextRenderServerStack, NextRenderServerStackProps>(
`${siteNameLowerCased}-server`,
NextRenderServerStack,
Expand Down Expand Up @@ -152,20 +160,14 @@ 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.
}
}
)
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)
Expand Down
36 changes: 18 additions & 18 deletions src/lambdas/originRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@ import type { CloudFrontRequestEvent, CloudFrontRequestCallback, CloudFrontReque
import crypto from 'node:crypto'
import { CacheConfig } from '../types'
import {
makeHTTPRequest,
convertCloudFrontHeaders,
transformQueryToObject,
transformCookiesToObject,
getCurrentDeviceType,
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
41 changes: 1 addition & 40 deletions src/lambdas/utils/request.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down