From 5ffb72ffd594b73c5cfd094edd4d9281acfd77cb Mon Sep 17 00:00:00 2001 From: Roman Bobrovskyi Date: Thu, 20 Feb 2025 12:46:14 +0100 Subject: [PATCH] feat: added support of nextjs rewrites --- src/build/next.ts | 51 ++++++++++++++--- src/cdk/constructs/OriginRequestLambdaEdge.ts | 11 ++-- src/cdk/stacks/NextCloudfrontStack.ts | 13 +++-- src/commands/deploy.ts | 5 +- src/lambdas/originRequest.ts | 56 ++++++++++++++++++- src/types/index.ts | 10 ++++ 6 files changed, 125 insertions(+), 21 deletions(-) diff --git a/src/build/next.ts b/src/build/next.ts index 6e52ea6..fdc18bd 100644 --- a/src/build/next.ts +++ b/src/build/next.ts @@ -3,6 +3,7 @@ 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 { NextRewrites } from '../types' interface BuildOptions { packager: ProjectPackager @@ -40,7 +41,32 @@ const copyAssets = async (outputPath: string, appPath: string, appRelativePath: ) } -export const getNextCachedRoutesMatchers = async (outputPath: string, appRelativePath: string): Promise => { +const getRewritesConfig = (manifestRules: RoutesManifest['rewrites']): NextRewrites => { + if (!manifestRules) { + return [] + } + + if (Array.isArray(manifestRules)) { + return manifestRules.map((rule) => ({ + source: rule.source, + destination: rule.destination, + regex: rule.regex, + has: rule.has + })) + } + + return [...manifestRules.beforeFiles, ...manifestRules.afterFiles, ...manifestRules.fallback].map((rule) => ({ + source: rule.source, + destination: rule.destination, + regex: rule.regex, + has: rule.has + })) +} + +export const getNextCachedRoutesConfig = async ( + outputPath: string, + appRelativePath: string +): Promise<{ cachedRoutesMatchers: string[]; rewritesConfig: NextRewrites }> => { const prerenderManifestJSON = await fs.readFile( path.join(outputPath, '.next', 'standalone', appRelativePath, '.next', 'prerender-manifest.json'), 'utf-8' @@ -53,13 +79,20 @@ export const getNextCachedRoutesMatchers = async (outputPath: string, appRelativ 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) - } + const cachedRoutesMatchers = [...routesManifest.dynamicRoutes, ...routesManifest.staticRoutes].reduce( + (prev, route) => { + if (prerenderManifest.routes?.[route.page] || prerenderManifest.dynamicRoutes?.[route.page]) { + prev.push(route.regex) + } + + return prev + }, + [] as string[] + ) + + const rewritesConfig = getRewritesConfig(routesManifest.rewrites) - return prev - }, [] as string[]) + return { cachedRoutesMatchers, rewritesConfig } } export const buildApp = async (options: BuildAppOptions) => { @@ -74,7 +107,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) + const { cachedRoutesMatchers, rewritesConfig } = await getNextCachedRoutesConfig(outputPath, appRelativePath) - return { nextCachedRoutesMatchers } + return { cachedRoutesMatchers, rewritesConfig } } diff --git a/src/cdk/constructs/OriginRequestLambdaEdge.ts b/src/cdk/constructs/OriginRequestLambdaEdge.ts index f9764fd..a662fd1 100644 --- a/src/cdk/constructs/OriginRequestLambdaEdge.ts +++ b/src/cdk/constructs/OriginRequestLambdaEdge.ts @@ -6,7 +6,7 @@ import * as logs from 'aws-cdk-lib/aws-logs' import * as iam from 'aws-cdk-lib/aws-iam' import path from 'node:path' import { buildLambda } from '../../common/esbuild' -import { CacheConfig } from '../../types' +import { CacheConfig, NextRewrites } from '../../types' interface OriginRequestLambdaEdgeProps extends cdk.StackProps { bucketName: string @@ -15,7 +15,8 @@ interface OriginRequestLambdaEdgeProps extends cdk.StackProps { nodejs?: string cacheConfig: CacheConfig bucketRegion?: string - nextCachedRoutesMatchers: string[] + cachedRoutesMatchers: string[] + rewritesConfig: NextRewrites } const NodeJSEnvironmentMapping: Record = { @@ -34,7 +35,8 @@ export class OriginRequestLambdaEdge extends Construct { nodejs, buildOutputPath, cacheConfig, - nextCachedRoutesMatchers + cachedRoutesMatchers, + rewritesConfig } = props super(scope, id) @@ -47,7 +49,8 @@ export class OriginRequestLambdaEdge extends Construct { '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.NEXT_CACHED_ROUTES_MATCHERS': JSON.stringify(nextCachedRoutesMatchers ?? []) + 'process.env.NEXT_CACHED_ROUTES_MATCHERS': JSON.stringify(cachedRoutesMatchers ?? []), + 'process.env.NEXT_REWRITES_CONFIG': JSON.stringify(rewritesConfig ?? []) } }) diff --git a/src/cdk/stacks/NextCloudfrontStack.ts b/src/cdk/stacks/NextCloudfrontStack.ts index 2a0d485..1e4f166 100644 --- a/src/cdk/stacks/NextCloudfrontStack.ts +++ b/src/cdk/stacks/NextCloudfrontStack.ts @@ -5,7 +5,7 @@ import { OriginRequestLambdaEdge } from '../constructs/OriginRequestLambdaEdge' import { CloudFrontDistribution } from '../constructs/CloudFrontDistribution' import { ViewerResponseLambdaEdge } from '../constructs/ViewerResponseLambdaEdge' import { ViewerRequestLambdaEdge } from '../constructs/ViewerRequestLambdaEdge' -import { DeployConfig, NextRedirects, NextI18nConfig } from '../../types' +import { DeployConfig, NextRedirects, NextI18nConfig, NextRewrites } from '../../types' export interface NextCloudfrontStackProps extends StackProps { nodejs?: string @@ -17,7 +17,8 @@ export interface NextCloudfrontStackProps extends StackProps { imageTTL?: number redirects?: NextRedirects nextI18nConfig?: NextI18nConfig - nextCachedRoutesMatchers: string[] + cachedRoutesMatchers: string[] + rewritesConfig: NextRewrites } export class NextCloudfrontStack extends Stack { @@ -37,8 +38,9 @@ export class NextCloudfrontStack extends Stack { deployConfig, imageTTL, redirects, - nextCachedRoutesMatchers, - nextI18nConfig + cachedRoutesMatchers, + nextI18nConfig, + rewritesConfig } = props this.originRequestLambdaEdge = new OriginRequestLambdaEdge(this, `${id}-OriginRequestLambdaEdge`, { @@ -48,7 +50,8 @@ export class NextCloudfrontStack extends Stack { buildOutputPath, cacheConfig: deployConfig.cache, bucketRegion: region, - nextCachedRoutesMatchers + cachedRoutesMatchers, + rewritesConfig }) this.viewerRequestLambdaEdge = new ViewerRequestLambdaEdge(this, `${id}-ViewerRequestLambdaEdge`, { diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts index 394fb24..4fff6cb 100644 --- a/src/commands/deploy.ts +++ b/src/commands/deploy.ts @@ -113,7 +113,7 @@ export const deploy = async (config: DeployConfig) => { const siteNameLowerCased = siteName.toLowerCase() // Build and zip app. - const { nextCachedRoutesMatchers } = await buildApp({ + const { cachedRoutesMatchers, rewritesConfig } = await buildApp({ projectSettings, outputPath }) @@ -157,7 +157,8 @@ export const deploy = async (config: DeployConfig) => { imageTTL: nextConfig.imageTTL, nextI18nConfig, redirects: nextRedirects, - nextCachedRoutesMatchers, + cachedRoutesMatchers, + rewritesConfig, env: { region: AWS_EDGE_REGION // required since Edge can be deployed only here. } diff --git a/src/lambdas/originRequest.ts b/src/lambdas/originRequest.ts index f66dd26..d52696c 100644 --- a/src/lambdas/originRequest.ts +++ b/src/lambdas/originRequest.ts @@ -1,7 +1,7 @@ import { S3Client, HeadObjectCommand } from '@aws-sdk/client-s3' import type { CloudFrontRequestEvent, CloudFrontRequestCallback, CloudFrontRequest, Context } from 'aws-lambda' import crypto from 'node:crypto' -import { CacheConfig } from '../types' +import { CacheConfig, NextRewrites, NextRewriteEntity } from '../types' import { transformQueryToObject, transformCookiesToObject, @@ -93,6 +93,57 @@ const shouldRevalidateFile = (s3FileMeta: { LastModified: Date | string; CacheCo return isFileExpired } +/** + * Validates if a CloudFront request matches the conditions specified in the 'has' property of a rewrite rule + * @param request - The CloudFront request object to validate + * @param has - Array of conditions to check (header, query param, or cookie) + * @returns True if all conditions match or if no conditions specified, false otherwise + */ +const validateRouteHasMatch = (request: CloudFrontRequest, has: NextRewriteEntity['has']) => { + if (!has) return true + + return has.every((h) => { + if (h.type === 'header') { + const header = request.headers[h.key] + + return h.value ? header?.some((header) => header.value === h.value) : !!header + } + + if (h.type === 'query' && request.querystring) { + const searchParams = new URLSearchParams(request.querystring) + + return h.value ? searchParams.get(h.key) === h.value : searchParams.has(h.key) + } + + if (h.type === 'cookie') { + const cookies = request.headers.cookie?.[0].value + + return cookies?.includes(`${h.key}=${h.value ?? ''}`) + } + + return false + }) +} + +/** + * Checks if a CloudFront request matches any rewrite rules and updates the URI if matched + * @param request - The CloudFront request object to validate and potentially modify + * @param rewritesConfig - Array of rewrite rules to check against + */ +const validateRewriteRoute = (request: CloudFrontRequest, rewritesConfig: NextRewrites) => { + const rewriteRoute = rewritesConfig.find((rewrite) => { + const { regex, has } = rewrite + + const hasMatches = validateRouteHasMatch(request, has) + + return hasMatches && new RegExp(regex).test(request.uri) + }) + + if (rewriteRoute) { + request.uri = rewriteRoute.destination + } +} + export const handler = async ( event: CloudFrontRequestEvent, _context: Context, @@ -102,9 +153,12 @@ export const handler = async ( 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 nextRewritesConfig = process.env.NEXT_REWRITES_CONFIG as unknown as NextRewrites const { s3Key } = getS3ObjectPath(request, cacheConfig) const ebAppUrl = process.env.EB_APP_URL! + validateRewriteRoute(request, nextRewritesConfig) + const isCachedRoute = nextCachedRoutesMatchers.some((matcher) => RegExp(matcher).test(request.uri)) try { diff --git a/src/types/index.ts b/src/types/index.ts index 4f40cf1..1a933cd 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,4 +1,5 @@ import type { NextConfig } from 'next/types' +import type { RouteHas } from 'next/dist/lib/load-custom-routes' export interface CacheConfig { noCacheRoutes?: string[] @@ -14,3 +15,12 @@ export interface DeployConfig { export type NextRedirects = Awaited['redirects']>> export type NextI18nConfig = NextConfig['i18n'] + +export type NextRewriteEntity = { + source: string + destination: string + regex: string + has?: RouteHas[] +} + +export type NextRewrites = NextRewriteEntity[]