From b2810fd7d029b23894abd7381fcbd5e11baecbea Mon Sep 17 00:00:00 2001 From: Roman Bobrovskyi Date: Fri, 28 Feb 2025 10:32:44 +0100 Subject: [PATCH] feat: added next routing for redirects and rewrites --- src/build/next.ts | 31 +++++-- src/cdk/constructs/OriginRequestLambdaEdge.ts | 18 +--- .../constructs/RenderServerDistribution.ts | 4 +- .../constructs/RenderWorkerDistribution.ts | 4 +- src/cdk/constructs/ViewerRequestLambdaEdge.ts | 14 +-- src/cdk/stacks/NextCloudfrontStack.ts | 17 ++-- src/commands/deploy.ts | 7 +- src/lambdas/originRequest.ts | 56 +----------- src/lambdas/utils/nextRoute.ts | 88 +++++++++++++++++++ src/lambdas/viewerRequest.ts | 53 +++++------ src/types/index.ts | 12 ++- 11 files changed, 183 insertions(+), 121 deletions(-) create mode 100644 src/lambdas/utils/nextRoute.ts diff --git a/src/build/next.ts b/src/build/next.ts index 7481be1..5a25227 100644 --- a/src/build/next.ts +++ b/src/build/next.ts @@ -3,7 +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' +import { NextRewrites, NextRedirects } from '../types' interface BuildOptions { packager: ProjectPackager @@ -66,10 +66,26 @@ const getRewritesConfig = (manifestRules: RoutesManifest['rewrites']): NextRewri })) } +const getRedirectsConfig = (manifestRedirects: RoutesManifest['redirects']): NextRedirects => { + if (!manifestRedirects) { + return [] + } + + return manifestRedirects.map((rule) => ({ + source: rule.source, + destination: rule.destination, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + regex: rule.regex, // nextjs still generates for manifest file regex to match the route + statusCode: rule.statusCode ?? 307, + has: rule.has + })) +} + export const getNextCachedRoutesConfig = async ( outputPath: string, appRelativePath: string -): Promise<{ cachedRoutesMatchers: string[]; rewritesConfig: NextRewrites }> => { +): Promise<{ cachedRoutesMatchers: string[]; rewritesConfig: NextRewrites; redirectsConfig: NextRedirects }> => { const prerenderManifestJSON = await fs.readFile( path.join(outputPath, '.next', 'standalone', appRelativePath, '.next', 'prerender-manifest.json'), 'utf-8' @@ -95,7 +111,9 @@ export const getNextCachedRoutesConfig = async ( const rewritesConfig = getRewritesConfig(routesManifest.rewrites) - return { cachedRoutesMatchers, rewritesConfig } + const redirectsConfig = getRedirectsConfig(routesManifest.redirects) + + return { cachedRoutesMatchers, rewritesConfig, redirectsConfig } } export const buildApp = async (options: BuildAppOptions) => { @@ -110,7 +128,10 @@ export const buildApp = async (options: BuildAppOptions) => { const appRelativePath = isMonorepo ? path.relative(root, projectPath) : '' await copyAssets(outputPath, projectPath, appRelativePath) - const { cachedRoutesMatchers, rewritesConfig } = await getNextCachedRoutesConfig(outputPath, appRelativePath) + const { cachedRoutesMatchers, rewritesConfig, redirectsConfig } = await getNextCachedRoutesConfig( + outputPath, + appRelativePath + ) - return { cachedRoutesMatchers, rewritesConfig } + return { cachedRoutesMatchers, rewritesConfig, redirectsConfig } } diff --git a/src/cdk/constructs/OriginRequestLambdaEdge.ts b/src/cdk/constructs/OriginRequestLambdaEdge.ts index a662fd1..34e9a8d 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, NextRewrites } from '../../types' +import { CacheConfig } from '../../types' interface OriginRequestLambdaEdgeProps extends cdk.StackProps { bucketName: string @@ -16,7 +16,6 @@ interface OriginRequestLambdaEdgeProps extends cdk.StackProps { cacheConfig: CacheConfig bucketRegion?: string cachedRoutesMatchers: string[] - rewritesConfig: NextRewrites } const NodeJSEnvironmentMapping: Record = { @@ -28,16 +27,8 @@ 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, - cachedRoutesMatchers, - rewritesConfig - } = props + const { bucketName, bucketRegion, renderServerDomain, nodejs, buildOutputPath, cacheConfig, cachedRoutesMatchers } = + props super(scope, id) const nodeJSEnvironment = NodeJSEnvironmentMapping[nodejs ?? ''] ?? NodeJSEnvironmentMapping['20'] @@ -49,8 +40,7 @@ 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(cachedRoutesMatchers ?? []), - 'process.env.NEXT_REWRITES_CONFIG': JSON.stringify(rewritesConfig ?? []) + 'process.env.NEXT_CACHED_ROUTES_MATCHERS': JSON.stringify(cachedRoutesMatchers ?? []) } }) diff --git a/src/cdk/constructs/RenderServerDistribution.ts b/src/cdk/constructs/RenderServerDistribution.ts index 4020df4..3096b97 100644 --- a/src/cdk/constructs/RenderServerDistribution.ts +++ b/src/cdk/constructs/RenderServerDistribution.ts @@ -20,8 +20,8 @@ interface RenderServerDistributionProps { } const NodeJSEnvironmentMapping: Record = { - '18': '64bit Amazon Linux 2023 v6.2.2 running Node.js 18', - '20': '64bit Amazon Linux 2023 v6.2.2 running Node.js 20' + '18': '64bit Amazon Linux 2023 v6.4.3 running Node.js 18', + '20': '64bit Amazon Linux 2023 v6.4.3 running Node.js 20' } export class RenderServerDistribution extends Construct { diff --git a/src/cdk/constructs/RenderWorkerDistribution.ts b/src/cdk/constructs/RenderWorkerDistribution.ts index 4c9449c..03159e1 100644 --- a/src/cdk/constructs/RenderWorkerDistribution.ts +++ b/src/cdk/constructs/RenderWorkerDistribution.ts @@ -12,8 +12,8 @@ import { addOutput } from '../../common/cdk' * Maps version numbers to their corresponding Amazon Linux 2023 solution stack names */ const NODE_VERSIONS: Record = { - '18': '64bit Amazon Linux 2023 v6.2.2 running Node.js 18', - '20': '64bit Amazon Linux 2023 v6.2.2 running Node.js 20' + '18': '64bit Amazon Linux 2023 v6.4.3 running Node.js 18', + '20': '64bit Amazon Linux 2023 v6.4.3 running Node.js 20' } as const /** diff --git a/src/cdk/constructs/ViewerRequestLambdaEdge.ts b/src/cdk/constructs/ViewerRequestLambdaEdge.ts index 591d9b8..ad7cad0 100644 --- a/src/cdk/constructs/ViewerRequestLambdaEdge.ts +++ b/src/cdk/constructs/ViewerRequestLambdaEdge.ts @@ -6,13 +6,15 @@ 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 { NextRedirects, NextI18nConfig } from '../../types' +import { NextRedirects, NextI18nConfig, NextRewrites } from '../../types' interface ViewerRequestLambdaEdgeProps extends cdk.StackProps { buildOutputPath: string nodejs?: string - redirects?: NextRedirects + redirectsConfig?: NextRedirects nextI18nConfig?: NextI18nConfig + isTrailingSlashEnabled: boolean + rewritesConfig: NextRewrites } const NodeJSEnvironmentMapping: Record = { @@ -24,7 +26,7 @@ export class ViewerRequestLambdaEdge extends Construct { public readonly lambdaEdge: cloudfront.experimental.EdgeFunction constructor(scope: Construct, id: string, props: ViewerRequestLambdaEdgeProps) { - const { nodejs, buildOutputPath, redirects, nextI18nConfig } = props + const { nodejs, buildOutputPath, redirectsConfig, nextI18nConfig, isTrailingSlashEnabled, rewritesConfig } = props super(scope, id) const nodeJSEnvironment = NodeJSEnvironmentMapping[nodejs ?? ''] ?? NodeJSEnvironmentMapping['20'] @@ -32,8 +34,10 @@ export class ViewerRequestLambdaEdge extends Construct { buildLambda(name, buildOutputPath, { define: { - 'process.env.REDIRECTS': JSON.stringify(redirects ?? []), - 'process.env.LOCALES_CONFIG': JSON.stringify(nextI18nConfig ?? null) + 'process.env.REDIRECTS': JSON.stringify(redirectsConfig ?? []), + 'process.env.LOCALES_CONFIG': JSON.stringify(nextI18nConfig ?? null), + 'process.env.IS_TRAILING_SLASH_ENABLED': JSON.stringify(isTrailingSlashEnabled), + 'process.env.NEXT_REWRITES_CONFIG': JSON.stringify(rewritesConfig ?? []) } }) diff --git a/src/cdk/stacks/NextCloudfrontStack.ts b/src/cdk/stacks/NextCloudfrontStack.ts index 4626797..18741c7 100644 --- a/src/cdk/stacks/NextCloudfrontStack.ts +++ b/src/cdk/stacks/NextCloudfrontStack.ts @@ -15,10 +15,11 @@ export interface NextCloudfrontStackProps extends StackProps { buildOutputPath: string deployConfig: DeployConfig imageTTL?: number - redirects?: NextRedirects + redirectsConfig?: NextRedirects nextI18nConfig?: NextI18nConfig cachedRoutesMatchers: string[] rewritesConfig: NextRewrites + isTrailingSlashEnabled: boolean } export class NextCloudfrontStack extends Stack { @@ -37,10 +38,11 @@ export class NextCloudfrontStack extends Stack { region, deployConfig, imageTTL, - redirects, + redirectsConfig, cachedRoutesMatchers, nextI18nConfig, - rewritesConfig + rewritesConfig, + isTrailingSlashEnabled } = props this.originRequestLambdaEdge = new OriginRequestLambdaEdge(this, `${id}-OriginRequestLambdaEdge`, { @@ -50,15 +52,16 @@ export class NextCloudfrontStack extends Stack { buildOutputPath, cacheConfig: deployConfig.cache, bucketRegion: region, - cachedRoutesMatchers, - rewritesConfig + cachedRoutesMatchers }) this.viewerRequestLambdaEdge = new ViewerRequestLambdaEdge(this, `${id}-ViewerRequestLambdaEdge`, { buildOutputPath, nodejs, - redirects, - nextI18nConfig + redirectsConfig, + rewritesConfig, + nextI18nConfig, + isTrailingSlashEnabled }) this.viewerResponseLambdaEdge = new ViewerResponseLambdaEdge(this, `${id}-ViewerResponseLambdaEdge`, { diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts index d9bb13a..09f94cf 100644 --- a/src/commands/deploy.ts +++ b/src/commands/deploy.ts @@ -85,8 +85,8 @@ export const deploy = async (config: DeployConfig) => { const deployConfig = await loadConfig() const nextConfig = (await loadFile(projectSettings.nextConfigPath)) as NextConfig - const nextRedirects = nextConfig.redirects ? await nextConfig.redirects() : undefined const nextI18nConfig = nextConfig.i18n + const isTrailingSlashEnabled = nextConfig.trailingSlash ?? false const outputPath = createOutputFolder() @@ -113,7 +113,7 @@ export const deploy = async (config: DeployConfig) => { const siteNameLowerCased = siteName.toLowerCase() // Build and zip app. - const { cachedRoutesMatchers, rewritesConfig } = await buildApp({ + const { cachedRoutesMatchers, rewritesConfig, redirectsConfig } = await buildApp({ projectSettings, outputPath }) @@ -156,9 +156,10 @@ export const deploy = async (config: DeployConfig) => { deployConfig, imageTTL: nextConfig.imageTTL, nextI18nConfig, - redirects: nextRedirects, + redirectsConfig, cachedRoutesMatchers, rewritesConfig, + isTrailingSlashEnabled, 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 d52696c..f66dd26 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, NextRewrites, NextRewriteEntity } from '../types' +import { CacheConfig } from '../types' import { transformQueryToObject, transformCookiesToObject, @@ -93,57 +93,6 @@ 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, @@ -153,12 +102,9 @@ 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/lambdas/utils/nextRoute.ts b/src/lambdas/utils/nextRoute.ts new file mode 100644 index 0000000..b7acea1 --- /dev/null +++ b/src/lambdas/utils/nextRoute.ts @@ -0,0 +1,88 @@ +/* eslint-disable no-useless-escape */ +import type { CloudFrontRequest } from 'aws-lambda' +import type { NextRewriteEntity, NextRewrites, NextRedirects } from '../../types' + +/** + * Validates if a request matches the specified route conditions + * @param request - The CloudFront request object to validate + * @param has - Array of conditions to check (headers, query params, cookies) + * @returns boolean indicating if all conditions match + */ +export 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 + }) +} + +/** + * Processes a request against rewrite/redirect rules to determine the updated route + * @param request - The CloudFront request object to process + * @param rules - Array of rewrite or redirect rules to apply + * @param isTrailingSlashEnabled - Whether trailing slashes should be enforced + * @returns Object containing the new URL and matched rule, or undefined if no match + */ +export const getUpdatedRoute = ( + request: CloudFrontRequest, + rules: NextRewrites | NextRedirects, + isTrailingSlashEnabled: boolean +) => { + for (const rule of rules) { + const { regex, has, source, destination } = rule + const hasMatches = validateRouteHasMatch(request, has) + + if (hasMatches) { + const regexFn = new RegExp(regex) + const match = regexFn.exec(request.uri) + + if (match) { + const paramNames = source.match(/\:(\w+)(\*)?/g)?.map((param) => param.replace(/[:*]/g, '')) || [] + + // Create params object by mapping names to capture groups + // First group [0] is full match, so we start from [1] + const params = paramNames.reduce( + (acc, name, index) => { + acc[name] = match[index + 1] || '' + return acc + }, + {} as Record + ) + + // Replace parameters in destination + let updatedUrlDestintaion = destination.replace(/\:(\w+)(\*)?/g, (_, name) => { + return params[name] || '' + }) + const containsTrailingSlash = updatedUrlDestintaion.endsWith('/') + + if (isTrailingSlashEnabled && !containsTrailingSlash) { + updatedUrlDestintaion = `${updatedUrlDestintaion}/` + } + + if (!isTrailingSlashEnabled && containsTrailingSlash) { + updatedUrlDestintaion = updatedUrlDestintaion.slice(0, -1) + } + + return { newUrl: updatedUrlDestintaion, rule } + } + } + } +} diff --git a/src/lambdas/viewerRequest.ts b/src/lambdas/viewerRequest.ts index 44db9a4..846f329 100644 --- a/src/lambdas/viewerRequest.ts +++ b/src/lambdas/viewerRequest.ts @@ -1,6 +1,7 @@ import type { CloudFrontRequestCallback, Context, CloudFrontResponseEvent } from 'aws-lambda' -import type { NextRedirects, NextI18nConfig } from '../types' +import type { NextRedirects, NextI18nConfig, NextRewrites } from '../types' import path from 'node:path' +import { getUpdatedRoute } from './utils/nextRoute' /** * AWS Lambda@Edge Viewer Request handler for Next.js redirects @@ -19,47 +20,47 @@ export const handler = async ( const request = event.Records[0].cf.request const redirectsConfig = process.env.REDIRECTS as unknown as NextRedirects const localesConfig = process.env.LOCALES_CONFIG as unknown as NextI18nConfig | null + const isTrailingSlashEnabled = process.env.IS_TRAILING_SLASH_ENABLED as unknown as boolean + const nextRewritesConfig = process.env.NEXT_REWRITES_CONFIG as unknown as NextRewrites let shouldRedirectWithLocale = false let pagePath = request.uri - let locale = '' - let redirectTo = '' - let redirectStatus = '307' + + if (request.uri.startsWith('/api/')) { + return callback(null, request) + } if (localesConfig) { - const [requestLocale, ...restPath] = request.uri.substring(1).split('/') - shouldRedirectWithLocale = !localesConfig.locales.find((locale) => locale === requestLocale) + const [requestLocale] = request.uri.substring(1).split('/') + shouldRedirectWithLocale = !localesConfig.locales.includes(requestLocale) - if (!shouldRedirectWithLocale) { - pagePath = `/${restPath.join('/')}` - locale = requestLocale - } else { - locale = localesConfig.defaultLocale + if (shouldRedirectWithLocale) { + pagePath = path.join(`/${localesConfig.defaultLocale}`, pagePath) } } - const redirect = redirectsConfig.find((r) => r.source === pagePath) + const redirectDestintaion = getUpdatedRoute({ ...request, uri: pagePath }, redirectsConfig, isTrailingSlashEnabled) - if (redirect) { - redirectTo = locale ? `/${path.join(locale, redirect.destination)}` : redirect.destination - redirectStatus = redirect.statusCode ? String(redirect.statusCode) : redirect.permanent ? '308' : '307' - } else if (shouldRedirectWithLocale) { - redirectTo = `/${path.join(locale, pagePath)}` - } + if (redirectDestintaion || shouldRedirectWithLocale) { + const redirectPath = redirectDestintaion ? redirectDestintaion.newUrl : pagePath + const statusCode = + redirectDestintaion && 'statusCode' in redirectDestintaion.rule + ? String(redirectDestintaion.rule.statusCode) + : '307' - if (redirectTo) { return callback(null, { - status: redirectStatus, + status: statusCode, headers: { - location: [ - { - key: 'Location', - value: `${redirectTo}${request.querystring ? `?${request.querystring}` : ''}` - } - ] + location: [{ key: 'Location', value: redirectPath }] } }) } + const rewrittenDestination = getUpdatedRoute(request, nextRewritesConfig, isTrailingSlashEnabled) + + if (rewrittenDestination) { + request.uri = rewrittenDestination.newUrl + } + return callback(null, request) } diff --git a/src/types/index.ts b/src/types/index.ts index c7cfba6..0b7779c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -16,8 +16,6 @@ export interface DeployConfig { } } -export type NextRedirects = Awaited['redirects']>> - export type NextI18nConfig = NextConfig['i18n'] export type NextRewriteEntity = { @@ -27,4 +25,14 @@ export type NextRewriteEntity = { has?: RouteHas[] } +export type NextRedirectEntity = { + source: string + destination: string + has?: RouteHas[] + regex: string + statusCode: number +} + export type NextRewrites = NextRewriteEntity[] + +export type NextRedirects = NextRedirectEntity[]