From 2db1593917eba0e19fc232d2af3d0d8bc1000b86 Mon Sep 17 00:00:00 2001 From: Mischa Spiegelmock Date: Mon, 17 Oct 2022 23:27:04 -0700 Subject: [PATCH] Improve bundling of various lambda functions --- .eslintrc.json | 7 +- .projen/deps.json | 4 + .projenrc.js | 13 ++- API.md | 94 +++++++++++++------ README.md | 13 ++- assets/lambda/NextJsHandler.ts | 1 + assets/lambda/S3StaticEnvRewriter.ts | 72 +++++++++++++++ assets/lambda@edge/LambdaOriginRequest.ts | 26 ++++++ package.json | 2 + src/BundleFunction.ts | 18 +++- src/Nextjs.ts | 80 +++++++--------- src/NextjsAssetsDeployment.ts | 108 ++++++---------------- src/NextjsBase.ts | 7 +- src/NextjsBuild.ts | 20 ++-- src/NextjsLambda.ts | 11 +-- tsconfig.dev.json | 5 +- yarn.lock | 5 + 17 files changed, 311 insertions(+), 175 deletions(-) create mode 100644 assets/lambda/S3StaticEnvRewriter.ts create mode 100644 assets/lambda@edge/LambdaOriginRequest.ts diff --git a/.eslintrc.json b/.eslintrc.json index e5e340c3..db33bef7 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -36,7 +36,12 @@ } }, "ignorePatterns": [ - "assets/**/*" + "*.js", + "!.projenrc.js", + "*.d.ts", + "node_modules/", + "*.generated.ts", + "coverage" ], "rules": { "prettier/prettier": [ diff --git a/.projen/deps.json b/.projen/deps.json index 5975ba3c..caee3e1c 100644 --- a/.projen/deps.json +++ b/.projen/deps.json @@ -112,6 +112,10 @@ "name": "typescript", "type": "build" }, + { + "name": "@types/aws-lambda", + "type": "bundled" + }, { "name": "@types/cross-spawn", "type": "bundled" diff --git a/.projenrc.js b/.projenrc.js index 8086400b..0a7850a8 100644 --- a/.projenrc.js +++ b/.projenrc.js @@ -10,9 +10,15 @@ const project = new awscdk.AwsCdkConstructLibrary({ packageName: 'cdk-nextjs-standalone', description: 'Deploy a NextJS app to AWS using CDK. Uses standalone build and output tracing.', keywords: ['nextjs', 'next', 'aws-cdk', 'aws', 'cdk', 'standalone', 'iac', 'infrastructure', 'cloud', 'serverless'], - eslintOptions: { prettier: true, ignorePatterns: ['assets/**/*'] }, + eslintOptions: { + prettier: true, + // ignorePatterns: ['assets/**/*'] + }, majorVersion: 1, + tsconfig: { compilerOptions: { noUnusedLocals: false }, include: ['assets/**/*.ts'] }, + tsconfigDev: { compilerOptions: { noUnusedLocals: false } }, + bundledDeps: [ 'cross-spawn', 'fs-extra', @@ -21,6 +27,7 @@ const project = new awscdk.AwsCdkConstructLibrary({ '@types/cross-spawn', '@types/fs-extra', '@types/micromatch', + '@types/aws-lambda', 'esbuild', 'aws-lambda', 'serverless-http', @@ -30,4 +37,8 @@ const project = new awscdk.AwsCdkConstructLibrary({ // do not generate sample test files sampleCode: false, }); +// project.eslint.addOverride({ +// rules: {}, +// }); +// project.tsconfig.addInclude('assets/**/*.ts'); project.synth(); diff --git a/API.md b/API.md index 2503a245..023ecb50 100644 --- a/API.md +++ b/API.md @@ -2370,10 +2370,10 @@ const nextjsAssetsDeploymentProps: NextjsAssetsDeploymentProps = { ... } | environment | {[ key: string ]: string} | Custom environment variables to pass to the NextJS build and runtime. | | isPlaceholder | boolean | Skip building app and deploy a placeholder. | | nodeEnv | string | Optional value for NODE_ENV during build and runtime. | +| quiet | boolean | Less build output. | | tempBuildDir | string | Directory to store temporary build files in. | | nextBuild | NextjsBuild | The `NextjsBuild` instance representing the built Nextjs application. | | bucket | aws-cdk-lib.aws_s3.IBucket \| aws-cdk-lib.aws_s3.BucketProps | Properties for the S3 bucket containing the NextJS assets. | -| compressionLevel | number | Set to true to delete old assets (defaults to false). | | distribution | aws-cdk-lib.aws_cloudfront.IDistribution | Distribution to invalidate when assets change. | | prune | boolean | Set to true to delete old assets (defaults to false). | @@ -2444,6 +2444,18 @@ Optional value for NODE_ENV during build and runtime. --- +##### `quiet`Optional + +```typescript +public readonly quiet: boolean; +``` + +- *Type:* boolean + +Less build output. + +--- + ##### `tempBuildDir`Optional ```typescript @@ -2484,20 +2496,6 @@ You can also supply your own bucket here. --- -##### `compressionLevel`Optional - -```typescript -public readonly compressionLevel: number; -``` - -- *Type:* number - -Set to true to delete old assets (defaults to false). - -Recommended to only set to true if you don't need the ability to roll back deployments. - ---- - ##### `distribution`Optional ```typescript @@ -2545,6 +2543,7 @@ const nextjsBaseProps: NextjsBaseProps = { ... } | environment | {[ key: string ]: string} | Custom environment variables to pass to the NextJS build and runtime. | | isPlaceholder | boolean | Skip building app and deploy a placeholder. | | nodeEnv | string | Optional value for NODE_ENV during build and runtime. | +| quiet | boolean | Less build output. | | tempBuildDir | string | Directory to store temporary build files in. | --- @@ -2614,6 +2613,18 @@ Optional value for NODE_ENV during build and runtime. --- +##### `quiet`Optional + +```typescript +public readonly quiet: boolean; +``` + +- *Type:* boolean + +Less build output. + +--- + ##### `tempBuildDir`Optional ```typescript @@ -2647,6 +2658,7 @@ const nextjsBuildProps: NextjsBuildProps = { ... } | environment | {[ key: string ]: string} | Custom environment variables to pass to the NextJS build and runtime. | | isPlaceholder | boolean | Skip building app and deploy a placeholder. | | nodeEnv | string | Optional value for NODE_ENV during build and runtime. | +| quiet | boolean | Less build output. | | tempBuildDir | string | Directory to store temporary build files in. | --- @@ -2716,6 +2728,18 @@ Optional value for NODE_ENV during build and runtime. --- +##### `quiet`Optional + +```typescript +public readonly quiet: boolean; +``` + +- *Type:* boolean + +Less build output. + +--- + ##### `tempBuildDir`Optional ```typescript @@ -3308,6 +3332,7 @@ const nextjsLambdaProps: NextjsLambdaProps = { ... } | environment | {[ key: string ]: string} | Custom environment variables to pass to the NextJS build and runtime. | | isPlaceholder | boolean | Skip building app and deploy a placeholder. | | nodeEnv | string | Optional value for NODE_ENV during build and runtime. | +| quiet | boolean | Less build output. | | tempBuildDir | string | Directory to store temporary build files in. | | nextBuild | NextjsBuild | Built nextJS application. | | function | aws-cdk-lib.aws_lambda.FunctionOptions | Override function properties. | @@ -3379,6 +3404,18 @@ Optional value for NODE_ENV during build and runtime. --- +##### `quiet`Optional + +```typescript +public readonly quiet: boolean; +``` + +- *Type:* boolean + +Less build output. + +--- + ##### `tempBuildDir`Optional ```typescript @@ -3447,9 +3484,9 @@ const nextjsProps: NextjsProps = { ... } | environment | {[ key: string ]: string} | Custom environment variables to pass to the NextJS build and runtime. | | isPlaceholder | boolean | Skip building app and deploy a placeholder. | | nodeEnv | string | Optional value for NODE_ENV during build and runtime. | +| quiet | boolean | Less build output. | | tempBuildDir | string | Directory to store temporary build files in. | | cdk | NextjsCdkProps | Allows you to override default settings this construct uses internally to create the cloudfront distribution. | -| compressionLevel | number | 0 - no compression, fatest 9 - maximum compression, slowest. | | customDomain | string \| NextjsDomainProps | The customDomain for this website. Supports domains that are hosted either on [Route 53](https://aws.amazon.com/route53/) or externally. | | stageName | string | *No description.* | | waitForInvalidation | boolean | While deploying, waits for the CloudFront cache invalidation process to finish. | @@ -3521,6 +3558,18 @@ Optional value for NODE_ENV during build and runtime. --- +##### `quiet`Optional + +```typescript +public readonly quiet: boolean; +``` + +- *Type:* boolean + +Less build output. + +--- + ##### `tempBuildDir`Optional ```typescript @@ -3547,19 +3596,6 @@ Allows you to override default settings this construct uses internally to create --- -##### `compressionLevel`Optional - -```typescript -public readonly compressionLevel: number; -``` - -- *Type:* number -- *Default:* 1 - -0 - no compression, fatest 9 - maximum compression, slowest. - ---- - ##### `customDomain`Optional ```typescript diff --git a/README.md b/README.md index 9020481b..034f251a 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,17 @@ new Nextjs(this, 'Web', { }); ``` +If using a **monorepo**, you will [need](https://nextjs.org/docs/advanced-features/output-file-tracing#caveats) to point your `next.config.js` at the project root: + +```ts +{ + ... + experimental: { + outputFileTracingRoot: path.join(__dirname, '..'), // if your nextjs app lives one level deep + }, +} +``` + ## Documentation Available on [Construct Hub](https://constructs.dev/packages/cdk-nextjs-standalone/). @@ -74,7 +85,7 @@ All other required dependencies should be bundled by NextJs [output tracing](htt This module is largely made up of code from the above projects. -## Questions +## Open questions - Do we need to manually handle CloudFront invalidation? It looks like `BucketDeployment` takes care of that for us - How is the `public` dir supposed to be handled? (Right now using an OriginGroup to look in the S3 origin first and if 403/404 then try lambda origin) diff --git a/assets/lambda/NextJsHandler.ts b/assets/lambda/NextJsHandler.ts index 7d954683..03909e87 100644 --- a/assets/lambda/NextJsHandler.ts +++ b/assets/lambda/NextJsHandler.ts @@ -35,6 +35,7 @@ const config: Options = { dir: __dirname, minimalMode: true, }; +console.debug('Environment:', JSON.stringify(process.env, null, 2)); // next request handler const nextHandler = new NextNodeServer(config).getRequestHandler(); diff --git a/assets/lambda/S3StaticEnvRewriter.ts b/assets/lambda/S3StaticEnvRewriter.ts new file mode 100644 index 00000000..faa45490 --- /dev/null +++ b/assets/lambda/S3StaticEnvRewriter.ts @@ -0,0 +1,72 @@ +import type { CdkCustomResourceEvent, CdkCustomResourceHandler } from 'aws-lambda'; +import AWS from 'aws-sdk'; + +async function tryGetObject(bucket, key, tries) { + const s3 = new AWS.S3(); + try { + return await s3.getObject({ Bucket: bucket, Key: key }).promise(); + } catch (err) { + console.error('Failed to retrieve object', key, err); + // for now.. skip it. might be a rollback and the file is no longer available. + // // if access denied - wait a few seconds and try again + // if (err.code === 'AccessDenied' && tries < 10) { + // console.info('Retrying for object', key); + // await new Promise((res) => setTimeout(res, 5000)); + // return tryGetObject(bucket, key, ++tries); + // } else { + // throw err; + // } + } +} + +const doRewrites = async (event: CdkCustomResourceEvent) => { + // rewrite static files + const s3 = new AWS.S3(); + const { s3keys, bucket, replacements } = event.ResourceProperties; + if (!s3keys || !bucket || !replacements) { + console.error('Missing required properties'); + return; + } + const promises = s3keys.map(async (key) => { + // get file + const params = { Bucket: bucket, Key: key }; + console.info('Rewriting', key, 'in bucket', bucket); + const res = await tryGetObject(bucket, key, 0); + if (!res) return; + + // get body + const bodyPre = res.Body?.toString('utf-8'); + if (!bodyPre) return; + let bodyPost = bodyPre; + + // do replacements of tokens + Object.entries(replacements as Record).forEach(([token, value]) => { + bodyPost = bodyPost.replace(token, value); + }); + + // didn't change? + if (bodyPost === bodyPre) return; + + // upload + console.info('Rewrote', key, 'in bucket', bucket); + const putParams = { + ...params, + Body: bodyPost, + ContentType: res.ContentType, + ContentEncoding: res.ContentEncoding, + CacheControl: res.CacheControl, + }; + await s3.putObject(putParams).promise(); + }); + await Promise.all(promises); +}; + +// search and replace tokenized values of designated objects in s3 +export const handler: CdkCustomResourceHandler = async (event) => { + const requestType = event.RequestType; + if (requestType === 'Create' || requestType === 'Update') { + await doRewrites(event); + } + + return event; +}; diff --git a/assets/lambda@edge/LambdaOriginRequest.ts b/assets/lambda@edge/LambdaOriginRequest.ts new file mode 100644 index 00000000..3a45bc2a --- /dev/null +++ b/assets/lambda@edge/LambdaOriginRequest.ts @@ -0,0 +1,26 @@ +import url from 'url'; +import type { CloudFrontRequestHandler } from 'aws-lambda'; + +/** + * This fixes the "host" header to be the host of the origin. + * The origin is the lambda server function URL. + * If we don't provide its expected "host", it will not know how to route the request. + */ +export const handler: CloudFrontRequestHandler = (event, _context, callback) => { + const request = event.Records[0].cf.request; + // console.log(JSON.stringify(request, null, 2)) + + // get origin url from header + const originUrlHeader = request.origin?.custom?.customHeaders['x-origin-url']; + if (!originUrlHeader || !originUrlHeader[0]) { + console.error('Origin header wasn"t set correctly, cannot get origin url'); + return callback(null, request); + } + const urlHeader = originUrlHeader[0].value; + const originUrl = url.parse(urlHeader, true); + if (!originUrl.host) throw new Error('Origin url host is missing'); + + request.headers['x-forwarded-host'] = [{ key: 'x-forwarded-host', value: request.headers.host[0].value }]; + request.headers.host = [{ key: 'host', value: originUrl.host }]; + callback(null, request); +}; diff --git a/package.json b/package.json index 0da20553..354626c4 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "constructs": "^10.0.5" }, "dependencies": { + "@types/aws-lambda": "^8.10.107", "@types/cross-spawn": "^6.0.2", "@types/fs-extra": "^9.0.13", "@types/micromatch": "^4.0.2", @@ -78,6 +79,7 @@ "serverless-http": "^3.0.3" }, "bundledDependencies": [ + "@types/aws-lambda", "@types/cross-spawn", "@types/fs-extra", "@types/micromatch", diff --git a/src/BundleFunction.ts b/src/BundleFunction.ts index a3fdac91..fa9267d3 100644 --- a/src/BundleFunction.ts +++ b/src/BundleFunction.ts @@ -1,6 +1,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import { dirname } from 'path'; import * as esbuild from 'esbuild'; interface BundleFunctionArgs { @@ -10,13 +11,22 @@ interface BundleFunctionArgs { bundleOptions: esbuild.BuildOptions; } +export const ESM_BUNDLE_DEFAULTS: Partial = { + format: 'esm', + mainFields: ['module', 'main'], + banner: { + // https://github.com/evanw/esbuild/issues/1921 + js: `import { createRequire } from 'module'; const require = createRequire(import.meta.url);`, + }, +}; + /** * Compile a function handler with esbuild. - * @returns app bundle directory path + * @returns bundle directory path */ export function bundleFunction({ inputPath, outputPath, outputFilename, bundleOptions }: BundleFunctionArgs) { if (!outputPath) { - if (!outputFilename) outputFilename = 'index.js'; + if (!outputFilename) outputFilename = bundleOptions.format === 'esm' ? 'index.mjs' : 'index.js'; const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nextjs-bundling-')); outputPath = path.join(tempDir, outputFilename); } @@ -31,5 +41,7 @@ export function bundleFunction({ inputPath, outputPath, outputFilename, bundleOp throw new Error('There was a problem bundling the function.'); } - return outputPath; + // console.debug('Bundled ', inputPath, 'to', outputPath); + + return dirname(outputPath); } diff --git a/src/Nextjs.ts b/src/Nextjs.ts index 2a971e85..2e914b5c 100644 --- a/src/Nextjs.ts +++ b/src/Nextjs.ts @@ -1,5 +1,6 @@ import * as os from 'os'; import * as path from 'path'; +import { dirname } from 'path'; import { App, Duration, Fn, RemovalPolicy } from 'aws-cdk-lib'; import * as acm from 'aws-cdk-lib/aws-certificatemanager'; import * as cloudfront from 'aws-cdk-lib/aws-cloudfront'; @@ -13,6 +14,7 @@ import * as route53Targets from 'aws-cdk-lib/aws-route53-targets'; import * as s3 from 'aws-cdk-lib/aws-s3'; import { Construct } from 'constructs'; import * as fs from 'fs-extra'; +import { bundleFunction } from './BundleFunction'; import { NextJsAssetsDeployment, NextjsAssetsDeploymentProps } from './NextjsAssetsDeployment'; import { BaseSiteCdkDistributionProps, @@ -216,19 +218,27 @@ export class Nextjs extends Construct { constructor(scope: Construct, id: string, props: NextjsProps) { super(scope, id); + console.debug('┌ Building Next.js app ▼ ...'); + // get dir to store temp build files in this.tempBuildDir = props.tempBuildDir - ? path.resolve(path.join(props.tempBuildDir, `nextjs-cdk-build-${this.node.id}-${this.node.addr}`)) + ? path.resolve( + path.join(props.tempBuildDir, `nextjs-cdk-build-${this.node.id}-${this.node.addr.substring(0, 4)}`) + ) : fs.mkdtempSync(path.join(os.tmpdir(), 'nextjs-cdk-build-')); - this.props = props; - // this.configBucket = this.createConfigBucket(); + // save props + this.props = { ...props, tempBuildDir: this.tempBuildDir }; // build nextjs app - this.nextBuild = new NextjsBuild(this, id, props); - this.serverFunction = new NextJsLambda(this, 'Fn', { ...props, nextBuild: this.nextBuild, ...props.cdk?.lambda }); + this.nextBuild = new NextjsBuild(this, id, this.props); + this.serverFunction = new NextJsLambda(this, 'Fn', { + ...this.props, + nextBuild: this.nextBuild, + ...props.cdk?.lambda, + }); this.assetsDeployment = new NextJsAssetsDeployment(this, 'AssetDeployment', { - ...props, + ...this.props, ...props.cdk?.deployment, nextBuild: this.nextBuild, }); @@ -267,6 +277,8 @@ export class Nextjs extends Construct { // Connect Custom Domain to CloudFront Distribution this.createRoute53Records(); + + console.debug('└ Finished preparing NextJS app for deployment'); } ///////////////////// @@ -559,52 +571,30 @@ export class Nextjs extends Construct { * HTTP server so it needs the host header to be the address of the lambda and not * the distribution. * - * It also sets */ private buildLambdaOriginRequestEdgeFunction() { const app = App.of(this) as App; - // ridiculous error: The function execution role must be assumable with edgelambda.amazonaws.com as well as lambda.amazonaws.com principals. - // const role = new Role(this, 'NextEdgeLambdaRole', { - // assumedBy: new CompositePrincipal( - // new ServicePrincipal('lambda.amazonaws.com'), - // new ServicePrincipal('edgelambda.amazonaws.com') - // ), - // managedPolicies: [ - // ManagedPolicy.fromManagedPolicyArn( - // this, - // 'NextApiLambdaPolicy', - // 'arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole' - // ), - // ], - // }); + // bundle the edge function + const inputPath = path.join(__dirname, '..', 'assets', 'lambda@edge', 'LambdaOriginRequest'); + const outputPath = path.join(this.tempBuildDir, 'lambda@edge', 'LambdaOriginRequest.js'); + bundleFunction({ + inputPath, + outputPath, + bundleOptions: { + bundle: true, + external: ['aws-sdk', 'url'], + minify: true, + target: 'node16', + platform: 'node', + }, + }); const fn = new cloudfront.experimental.EdgeFunction(this, 'DefaultOriginRequestEdgeFn', { runtime: lambda.Runtime.NODEJS_16_X, // role, - handler: 'index.handler', - // code: lambda.Code.fromAsset(path.join(__dirname, '..', 'assets', 'lambda@edge', 'DefaultOriginRequest')), - code: lambda.Code.fromInline(` - const url = require('url'); - - exports.handler = (event, context, callback) => { - const request = event.Records[0].cf.request; - // console.log(JSON.stringify(request, null, 2)) - - // get origin url from header - const originUrlHeader = request.origin.custom.customHeaders['x-origin-url'] - if (!originUrlHeader || !originUrlHeader[0]) { - console.error('Origin header wasn"t set correctly, cannot get origin url') - return callback(null, request) - } - const urlHeader = originUrlHeader[0].value - const originUrl = url.parse(urlHeader, true); - - request.headers['x-forwarded-host'] = [ { key: 'x-forwarded-host', value: request.headers.host[0].value } ] - request.headers['host'] = [ { key: 'host', value: originUrl.host } ] - callback(null, request); - }; - `), + handler: 'LambdaOriginRequest.handler', + code: lambda.Code.fromAsset(dirname(outputPath)), currentVersionOptions: { removalPolicy: RemovalPolicy.DESTROY, // destroy old versions retryAttempts: 1, // async retry attempts @@ -612,8 +602,6 @@ export class Nextjs extends Construct { stackId: `Nextjs-${this.props.stageName || app.stageName || 'default'}-EdgeFunctions-` + this.node.addr.substring(0, 5), }); - // fn.grantInvoke(new ServicePrincipal('lambda.amazonaws.com')); - // fn.grantInvoke(new ServicePrincipal('edgelambda.amazonaws.com')); fn.currentVersion.grantInvoke(new ServicePrincipal('edgelambda.amazonaws.com')); fn.currentVersion.grantInvoke(new ServicePrincipal('lambda.amazonaws.com')); diff --git a/src/NextjsAssetsDeployment.ts b/src/NextjsAssetsDeployment.ts index db304147..35281371 100644 --- a/src/NextjsAssetsDeployment.ts +++ b/src/NextjsAssetsDeployment.ts @@ -10,6 +10,7 @@ import * as cr from 'aws-cdk-lib/custom-resources'; import { Construct } from 'constructs'; import * as fs from 'fs-extra'; import * as micromatch from 'micromatch'; +import { bundleFunction, ESM_BUNDLE_DEFAULTS } from './BundleFunction'; import { NextjsBaseProps } from './NextjsBase'; import { createArchive, makeTokenPlaceholder, NextjsBuild, replaceTokenGlobs } from './NextjsBuild'; @@ -69,18 +70,20 @@ export class NextJsAssetsDeployment extends Construct { this.bucket = this.createAssetBucket(); this.staticTempDir = this.prepareArchiveDirectory(); - this.deployments = this.uploadS3Assets(); + this.deployments = this.uploadS3Assets(this.staticTempDir); // do rewrites of unresolved CDK tokens in static files const rewriter = this.createRewriteResource(); - rewriter?.node.addDependency(this.deployments.map((deployment) => deployment.deployedBucket)); + rewriter?.node.addDependency(...this.deployments.map((deployment) => deployment.deployedBucket)); } // arrange directory structure for S3 asset deployments // should contain _next/static and ./ for public files protected prepareArchiveDirectory(): string { - const archiveDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nextjs-static-assets-')); - console.debug(`Static assets: ${archiveDir}`); + const archiveDir = this.props.tempBuildDir + ? path.resolve(path.join(this.props.tempBuildDir, 'static')) + : fs.mkdtempSync(path.join(os.tmpdir(), 'static-')); + fs.mkdirpSync(archiveDir); // theoretically we could move the files instead of copy for speed... @@ -101,7 +104,7 @@ export class NextJsAssetsDeployment extends Construct { }); } - // copy public files to root of bucket + // copy public files to root if (fs.existsSync(publicDir)) { fs.copySync(publicDir, archiveDir, { recursive: true, @@ -113,16 +116,14 @@ export class NextJsAssetsDeployment extends Construct { return archiveDir; } - private uploadS3Assets() { - // copy files - const archiveDir = this.prepareArchiveDirectory(); - + private uploadS3Assets(archiveDir: string) { // zip up bucket contents and upload to bucket const archiveZipFilePath = createArchive({ directory: archiveDir, zipFileName: 'assets.zip', - zipOutDir: this.props.nextBuild.tempBuildDir, + zipOutDir: path.join(this.props.nextBuild.tempBuildDir, 'assets'), compressionLevel: this.props.compressionLevel, + quiet: this.props.quiet, }); const deployment = new BucketDeployment(this, 'NextStaticAssetsS3Deployment', { @@ -137,83 +138,34 @@ export class NextJsAssetsDeployment extends Construct { } private createRewriteResource() { + return; const s3keys = this._getStaticFilesForRewrite(); if (s3keys.length === 0) return; // create a custom resource to find and replace tokenized strings in static files // must happen after deployment when tokens can be resolved + // compile function + const inputPath = path.resolve(__dirname, '../assets/lambda/S3StaticEnvRewriter.ts'); + const outputPath = path.join(this.props.nextBuild.tempBuildDir, 'deployment-scripts', 'S3StaticEnvRewriter.mjs'); + const handlerDir = bundleFunction({ + inputPath, + outputPath, + bundleOptions: { + bundle: true, + sourcemap: true, + external: ['aws-sdk'], + target: 'node16', + platform: 'node', + ...ESM_BUNDLE_DEFAULTS, + }, + }); + const rewriteFn = new lambda.Function(this, 'RewriteOnEventHandler', { runtime: lambda.Runtime.NODEJS_16_X, memorySize: 1024, timeout: Duration.minutes(5), - handler: 'index.handler', - code: lambda.Code.fromInline(` - const AWS = require("aws-sdk"); - - async function tryGetObject(bucket, key, tries) { - const s3 = new AWS.S3(); - try { - return await s3.getObject({ Bucket: bucket, Key: key }).promise(); - } catch (err) { - console.error("Failed to retrieve object", key, err); - // if access denied - wait a few seconds and try again - if (err.code === "AccessDenied" && tries < 10) { - console.info("Retrying for object", key); - await new Promise((res) => setTimeout(res, 5000)); - return tryGetObject(bucket, key, ++tries); - } else { - throw err; - } - } - } - - async function doRewrites(event) { - // rewrite static files - const s3 = new AWS.S3(); - const { s3keys, bucket, replacements } = event.ResourceProperties; - if (!s3keys || !bucket || !replacements) { - console.error("Missing required properties"); - return; - } - const promises = s3keys.map(async (key) => { - const params = { Bucket: bucket, Key: key }; - console.info("Rewriting", key, "in bucket", bucket); - const res = await tryGetObject(bucket, key, 0); - const bodyPre = res.Body.toString("utf-8"); - let bodyPost = bodyPre; - - // do replacements of tokens - Object.entries(replacements).forEach(([key, value]) => { - bodyPost = bodyPost.replace(key, value); - }); - - // didn't change? - if (bodyPost === bodyPre) return; - - // upload - console.info("Rewrote", key, "in bucket", bucket); - const putParams = { - ...params, - Body: bodyPost, - ContentType: res.ContentType, - ContentEncoding: res.ContentEncoding, - CacheControl: res.CacheControl, - }; - await s3.putObject(putParams).promise(); - }); - await Promise.all(promises); - } - - // search and replace tokenized values of designated objects in s3 - exports.handler = async (event) => { - const requestType = event.RequestType; - if (requestType === "Create" || requestType === "Update") { - await doRewrites(event); - } - - return event; - } - `), + handler: 'S3StaticEnvRewriter.handler', + code: lambda.Code.fromAsset(handlerDir), initialPolicy: [ new iam.PolicyStatement({ actions: ['s3:GetObject', 's3:PutObject'], diff --git a/src/NextjsBase.ts b/src/NextjsBase.ts index a38b8692..1c5ee445 100644 --- a/src/NextjsBase.ts +++ b/src/NextjsBase.ts @@ -27,7 +27,7 @@ export interface NextjsBaseProps { /** * Directory to store temporary build files in. - * Defaults to os.mkdtempSync(). + * Defaults to os.tmpdir(). */ readonly tempBuildDir?: string; // move to NextjsBuildProps? @@ -42,6 +42,11 @@ export interface NextjsBaseProps { * @default 1 */ readonly compressionLevel?: CompressionLevel; + + /** + * Less build output. + */ + readonly quiet?: boolean; } ///// stuff below taken from https://github.com/serverless-stack/sst/blob/8d377e941467ced81d8cc31ee67d5a06550f04d4/packages/resources/src/BaseSite.ts diff --git a/src/NextjsBuild.ts b/src/NextjsBuild.ts index f262e145..b0be766a 100644 --- a/src/NextjsBuild.ts +++ b/src/NextjsBuild.ts @@ -60,7 +60,7 @@ export class NextjsBuild extends Construct { // save config this.tempBuildDir = props.tempBuildDir - ? path.resolve(path.join(props.tempBuildDir, `nextjs-cdk-build`)) + ? path.resolve(props.tempBuildDir) : fs.mkdtempSync(path.join(os.tmpdir(), 'nextjs-cdk-build-')); this.props = props; @@ -98,17 +98,19 @@ export class NextjsBuild extends Construct { throw new Error(`No "build" script found within package.json in "${nextjsPath}".`); } - // Run build - console.debug('Running "npm build" script'); + // build environment vars const buildEnv = { ...process.env, [NEXTJS_BUILD_STANDALONE_ENV]: 'true', ...getBuildCmdEnvironment(this.props.environment), ...(this.props.nodeEnv ? { NODE_ENV: this.props.nodeEnv } : {}), }; + + // run build + console.debug('├ Running "npm build" in', nextjsPath); const buildResult = spawn.sync('npm', ['run', 'build'], { cwd: nextjsPath, - stdio: 'inherit', + stdio: this.props.quiet ? 'ignore' : 'inherit', env: buildEnv, }); if (buildResult.status !== 0) { @@ -173,6 +175,7 @@ export interface CreateArchiveArgs { readonly zipFileName: string; readonly zipOutDir: string; readonly fileGlob?: string; + readonly quiet?: boolean; } // zip up a directory and return path to zip file @@ -182,17 +185,20 @@ export function createArchive({ zipOutDir, fileGlob = '*', compressionLevel = 1, + quiet, }: CreateArchiveArgs): string { zipOutDir = path.resolve(zipOutDir); - // get output path - fs.removeSync(zipOutDir); fs.mkdirpSync(zipOutDir); + // get output path const zipFilePath = path.join(zipOutDir, zipFileName); // run script to create zipfile, preserving symlinks for node_modules (e.g. pnpm structure) const result = spawn.sync( 'bash', // getting ENOENT when specifying 'node' here for some reason - ['-xc', [`cd '${directory}'`, `zip -ryq${compressionLevel} '${zipFilePath}' ${fileGlob}`].join('&&')], + [ + quiet ? '-c' : '-xc', + [`cd '${directory}'`, `zip -ryq${compressionLevel} '${zipFilePath}' ${fileGlob}`].join('&&'), + ], { stdio: 'inherit' } ); if (result.status !== 0) { diff --git a/src/NextjsLambda.ts b/src/NextjsLambda.ts index d3fe83f6..d51159f3 100644 --- a/src/NextjsLambda.ts +++ b/src/NextjsLambda.ts @@ -90,17 +90,16 @@ export class NextJsLambda extends Function { // zip up the standalone directory const zipOutDir = path.resolve( - path.join( - props.tempBuildDir - ? path.resolve(path.join(props.tempBuildDir, `standalone`)) - : fs.mkdtempSync(path.join(os.tmpdir(), 'standalone-')) - ) + props.tempBuildDir + ? path.resolve(path.join(props.tempBuildDir, `standalone`)) + : fs.mkdtempSync(path.join(os.tmpdir(), 'standalone-')) ); const zipFilePath = createArchive({ directory: nextBuild.nextStandaloneDir, zipFileName: 'standalone.zip', zipOutDir, fileGlob: '*', + quiet: props.quiet, }); // build native deps layer @@ -133,7 +132,7 @@ export class NextJsLambda extends Function { // this can hold our resolved environment vars for the server protected createConfigBucket(props: NextjsLambdaProps) { // won't work until this is fixed: https://github.com/aws/aws-cdk/issues/19257 - const bucket = new Bucket(this, 'ConfigBucket', { removalPolicy: RemovalPolicy.DESTROY }); + const bucket = new Bucket(this, 'ConfigBucket', { removalPolicy: RemovalPolicy.DESTROY, autoDeleteObjects: true }); // convert environment vars to SSM parameters // (workaround for the above issue) diff --git a/tsconfig.dev.json b/tsconfig.dev.json index 2e29dbaa..de938ae8 100644 --- a/tsconfig.dev.json +++ b/tsconfig.dev.json @@ -15,7 +15,7 @@ "noImplicitAny": true, "noImplicitReturns": true, "noImplicitThis": true, - "noUnusedLocals": true, + "noUnusedLocals": false, "noUnusedParameters": true, "resolveJsonModule": true, "strict": true, @@ -27,7 +27,8 @@ "include": [ ".projenrc.js", "src/**/*.ts", - "test/**/*.ts" + "test/**/*.ts", + "assets/**/*.ts" ], "exclude": [ "node_modules" diff --git a/yarn.lock b/yarn.lock index 35eeb967..36e0b092 100644 --- a/yarn.lock +++ b/yarn.lock @@ -774,6 +774,11 @@ resolved "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz" integrity sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A== +"@types/aws-lambda@^8.10.107": + version "8.10.107" + resolved "https://registry.yarnpkg.com/@types/aws-lambda/-/aws-lambda-8.10.107.tgz#ef08cc6ceb63d786d58038dbe4f5412db68bc12d" + integrity sha512-UTI9ZPw4VzvgYUJ7gUU77/ovGrIniRRWiMWxms5dP+1fsxNPeP/cOopQ0mxXPc9NvbeROcDUDN34m8eEhdEitg== + "@types/babel__core@^7.0.0", "@types/babel__core@^7.1.14": version "7.1.19" resolved "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.19.tgz"