From 3ecaae948a5359e68d657e80aa1861bc2877133d Mon Sep 17 00:00:00 2001 From: Roman Bobrovskiy Date: Fri, 6 Dec 2024 09:27:57 +0100 Subject: [PATCH] feat: added localization support with redirects --- src/cdk/constructs/ViewerRequestLambdaEdge.ts | 10 ++-- src/cdk/stacks/NextCloudfrontStack.ts | 20 ++++---- src/commands/deploy.ts | 5 +- src/commands/helpers/createConfig.ts | 13 ++--- src/commands/helpers/loadConfig.ts | 4 +- src/lambdas/viewerRequest.ts | 48 +++++++++++++++++-- src/types/index.ts | 8 ++++ 7 files changed, 83 insertions(+), 25 deletions(-) diff --git a/src/cdk/constructs/ViewerRequestLambdaEdge.ts b/src/cdk/constructs/ViewerRequestLambdaEdge.ts index fb5f8d5..fac1241 100644 --- a/src/cdk/constructs/ViewerRequestLambdaEdge.ts +++ b/src/cdk/constructs/ViewerRequestLambdaEdge.ts @@ -6,12 +6,14 @@ 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 '../../build/edge' -import { NextRedirects } from '../../types' +import { NextRedirects, DeployConfig } from '../../types' interface ViewerRequestLambdaEdgeProps extends cdk.StackProps { buildOutputPath: string nodejs?: string redirects?: NextRedirects + internationalizationConfig?: DeployConfig['internationalization'] + trailingSlash?: boolean } const NodeJSEnvironmentMapping: Record = { @@ -23,7 +25,7 @@ export class ViewerRequestLambdaEdge extends Construct { public readonly lambdaEdge: cloudfront.experimental.EdgeFunction constructor(scope: Construct, id: string, props: ViewerRequestLambdaEdgeProps) { - const { nodejs, buildOutputPath } = props + const { nodejs, buildOutputPath, redirects, internationalizationConfig, trailingSlash = false } = props super(scope, id) const nodeJSEnvironment = NodeJSEnvironmentMapping[nodejs ?? ''] ?? NodeJSEnvironmentMapping['20'] @@ -31,7 +33,9 @@ export class ViewerRequestLambdaEdge extends Construct { buildLambda(name, buildOutputPath, { define: { - 'process.env.REDIRECTS': JSON.stringify(props.redirects ?? []) + 'process.env.REDIRECTS': JSON.stringify(redirects ?? []), + 'process.env.LOCALES_CONFIG': JSON.stringify(internationalizationConfig ?? null), + 'process.env.IS_TRAILING_SLASH': JSON.stringify(trailingSlash) } }) diff --git a/src/cdk/stacks/NextCloudfrontStack.ts b/src/cdk/stacks/NextCloudfrontStack.ts index fd88875..f8431d8 100644 --- a/src/cdk/stacks/NextCloudfrontStack.ts +++ b/src/cdk/stacks/NextCloudfrontStack.ts @@ -6,7 +6,7 @@ import { CloudFrontDistribution } from '../constructs/CloudFrontDistribution' import { OriginResponseLambdaEdge } from '../constructs/OriginResponseLambdaEdge' import { ViewerResponseLambdaEdge } from '../constructs/ViewerResponseLambdaEdge' import { ViewerRequestLambdaEdge } from '../constructs/ViewerRequestLambdaEdge' -import { CacheConfig, NextRedirects } from '../../types' +import { DeployConfig, NextRedirects } from '../../types' export interface NextCloudfrontStackProps extends StackProps { nodejs?: string @@ -16,9 +16,10 @@ export interface NextCloudfrontStackProps extends StackProps { renderWorkerQueueUrl: string renderWorkerQueueArn: string buildOutputPath: string - cacheConfig: CacheConfig + deployConfig: DeployConfig imageTTL?: number redirects?: NextRedirects + trailingSlash?: boolean } export class NextCloudfrontStack extends Stack { @@ -38,9 +39,10 @@ export class NextCloudfrontStack extends Stack { renderWorkerQueueUrl, renderWorkerQueueArn, region, - cacheConfig, + deployConfig, imageTTL, - redirects + redirects, + trailingSlash = false } = props this.originRequestLambdaEdge = new OriginRequestLambdaEdge(this, `${id}-OriginRequestLambdaEdge`, { @@ -48,7 +50,7 @@ export class NextCloudfrontStack extends Stack { bucketName: staticBucketName, renderServerDomain, buildOutputPath, - cacheConfig, + cacheConfig: deployConfig.cache, bucketRegion: region }) @@ -56,7 +58,7 @@ export class NextCloudfrontStack extends Stack { nodejs, renderWorkerQueueUrl, buildOutputPath, - cacheConfig, + cacheConfig: deployConfig.cache, renderWorkerQueueArn, region }) @@ -64,7 +66,9 @@ export class NextCloudfrontStack extends Stack { this.viewerRequestLambdaEdge = new ViewerRequestLambdaEdge(this, `${id}-ViewerRequestLambdaEdge`, { buildOutputPath, nodejs, - redirects + redirects, + internationalizationConfig: deployConfig.internationalization, + trailingSlash }) this.viewerResponseLambdaEdge = new ViewerResponseLambdaEdge(this, `${id}-ViewerResponseLambdaEdge`, { @@ -84,7 +88,7 @@ export class NextCloudfrontStack extends Stack { responseEdgeFunction: this.originResponseLambdaEdge.lambdaEdge, viewerResponseEdgeFunction: this.viewerResponseLambdaEdge.lambdaEdge, viewerRequestLambdaEdge: this.viewerRequestLambdaEdge.lambdaEdge, - cacheConfig, + cacheConfig: deployConfig.cache, imageTTL }) diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts index be3489c..0a4d052 100644 --- a/src/commands/deploy.ts +++ b/src/commands/deploy.ts @@ -82,7 +82,7 @@ export const deploy = async (config: DeployConfig) => { throw new Error('Was not able to find project settings.') } - const cacheConfig = await loadConfig() + const deployConfig = await loadConfig() const nextConfig = (await loadFile(projectSettings.nextConfigPath)) as NextConfig const nextRedirects = nextConfig.redirects ? await nextConfig.redirects() : undefined @@ -148,8 +148,9 @@ export const deploy = async (config: DeployConfig) => { buildOutputPath: outputPath, crossRegionReferences: true, region, - cacheConfig, + deployConfig, imageTTL: nextConfig.imageTTL, + trailingSlash: nextConfig.trailingSlash, redirects: nextRedirects, env: { region: AWS_EDGE_REGION // required since Edge can be deployed only here. diff --git a/src/commands/helpers/createConfig.ts b/src/commands/helpers/createConfig.ts index a4a830a..8f8f579 100644 --- a/src/commands/helpers/createConfig.ts +++ b/src/commands/helpers/createConfig.ts @@ -4,14 +4,15 @@ import { findConfig } from './loadConfig' const CONFIG_FILE_NAME = 'next-serverless.config.js' const CONFIG_TEMPLATE = `/** - * @type {import('@dbbs/next-serverless-deployment').CacheConfig} + * @type {import('@dbbs/next-serverless-deployment').DeployConfig} */ const config = { - noCacheRoutes: [], - cacheCookies: [], - cacheQueries: [], - enableDeviceSplit: false -} + cache: { + noCacheRoutes: [], + cacheCookies: [], + cacheQueries: [], + enableDeviceSplit: false} + } module.exports = config ` diff --git a/src/commands/helpers/loadConfig.ts b/src/commands/helpers/loadConfig.ts index 38dad4c..b900c4b 100644 --- a/src/commands/helpers/loadConfig.ts +++ b/src/commands/helpers/loadConfig.ts @@ -1,6 +1,6 @@ import fs from 'node:fs' import path from 'node:path' -import { CacheConfig } from '../../types' +import type { DeployConfig } from '../../types' export const findConfig = (configPath: string): string | undefined => { return ['next-serverless.config.js', 'next-serverless.config.mjs', 'next-serverless.config.ts'].find((config) => @@ -8,7 +8,7 @@ export const findConfig = (configPath: string): string | undefined => { ) } -async function loadConfig(): Promise { +async function loadConfig(): Promise { try { const serverConfig = findConfig(process.cwd()) diff --git a/src/lambdas/viewerRequest.ts b/src/lambdas/viewerRequest.ts index a1a16a5..fd1e421 100644 --- a/src/lambdas/viewerRequest.ts +++ b/src/lambdas/viewerRequest.ts @@ -1,5 +1,6 @@ import type { CloudFrontRequestCallback, Context, CloudFrontResponseEvent } from 'aws-lambda' -import type { NextRedirects } from '../types' +import type { NextRedirects, DeployConfig } from '../types' +import path from 'node:path' /** * AWS Lambda@Edge Viewer Request handler for Next.js redirects @@ -17,14 +18,53 @@ 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 DeployConfig['internationalization'] | null + const isTrailingSlash = process.env.IS_TRAILING_SLASH as unknown as boolean + const pathHasTrailingSlash = request.uri.endsWith('/') - const redirect = redirectsConfig.find((r) => r.source === request.uri) + if (pathHasTrailingSlash && !isTrailingSlash) { + request.uri = request.uri.slice(0, -1) + } else if (!pathHasTrailingSlash && isTrailingSlash) { + request.uri += '/' + } + + let shouldRedirectWithLocale = false + let pagePath = request.uri + let locale = '' + let redirectTo = '' + let redirectStatus = '307' + + if (localesConfig) { + const [requestLocale, ...restPath] = request.uri.substring(1).split('/') + shouldRedirectWithLocale = !localesConfig.locales.find((locale) => locale === requestLocale) + + if (!shouldRedirectWithLocale) { + pagePath = `/${restPath.join('/')}` + locale = requestLocale + } else { + locale = localesConfig.defaultLocale + } + } + + const redirect = redirectsConfig.find((r) => r.source === pagePath) 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 (redirectTo) { return callback(null, { - status: redirect.statusCode ? String(redirect.statusCode) : redirect.permanent ? '308' : '307', + status: redirectStatus, headers: { - location: [{ key: 'Location', value: redirect.destination }] + location: [ + { + key: 'Location', + value: `${redirectTo}${request.querystring ? `?${request.querystring}` : ''}` + } + ] } }) } diff --git a/src/types/index.ts b/src/types/index.ts index db8d4f6..8fe246a 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -7,4 +7,12 @@ export interface CacheConfig { enableDeviceSplit?: boolean } +export interface DeployConfig { + internationalization?: { + locales: string[] + defaultLocale: string + } + cache: CacheConfig +} + export type NextRedirects = Awaited['redirects']>>