Skip to content
This repository was archived by the owner on Feb 1, 2025. It is now read-only.

Commit cadf0e8

Browse files
authored
refactor: nextjs packaging, use npm packages to bundle instead of shell script, parameters added (#22)
Co-authored-by: Jan Soukup <[email protected]>
1 parent 23abd98 commit cadf0e8

File tree

6 files changed

+1180
-69
lines changed

6 files changed

+1180
-69
lines changed

Diff for: lib/cli.ts

+109-25
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,17 @@
11
#!/usr/bin/env node
2-
import { exec as child_exec } from 'child_process'
3-
import util from 'util'
2+
3+
import { Command } from 'commander'
4+
import { mkdirSync, rmSync, symlinkSync, writeFileSync } from 'fs'
5+
import { tmpdir } from 'os'
46
import path from 'path'
5-
import packageJson from '../package.json'
67
import { simpleGit } from 'simple-git'
7-
import { Command } from 'commander'
8-
import { bumpCalculator, bumpMapping, BumpType, isValidTag, replaceVersionInCommonFiles } from './utils'
9-
10-
const exec = util.promisify(child_exec)
8+
import packageJson from '../package.json'
9+
import { bumpCalculator, bumpMapping, BumpType, findInFile, isValidTag, replaceVersionInCommonFiles, zipFolder, zipMultipleFoldersOrFiles } from './utils'
1110

1211
const skipCiFlag = '[skip ci]'
13-
12+
const commandCwd = process.cwd()
13+
const nextServerConfigRegex = /(?<=conf: )(.*)(?=,)/
1414
const scriptDir = path.dirname(__filename)
15-
const scriptPath = path.resolve(`${scriptDir}/../../scripts/pack-nextjs.sh`)
16-
const handlerPath = path.resolve(`${scriptDir}/../server-handler/index.js`)
1715

1816
const program = new Command()
1917

@@ -26,21 +24,107 @@ program
2624
program
2725
.command('pack')
2826
.description('Package standalone Next12 build into Lambda compatible ZIPs.')
29-
.option('--output', 'folder where to save output', 'next.out')
30-
.option('--publicFolder', 'folder where public assets are located', 'public')
31-
.option('--handler', 'custom handler to deal with ApiGw events', handlerPath)
32-
.option('--grepBy', 'keyword to identify configuration inside server.js', 'webpack')
33-
.action(async (str, options) => {
34-
// @TODO: Ensure path exists.
35-
// @TODO: Ensure.next folder exists with standalone folder inside.
36-
37-
// @TODO: Transform into code, move away from script.
38-
// Also, pass parameters and options.
39-
console.log('Starting packaging of your NextJS project!')
40-
41-
await exec(`chmod +x ${scriptPath} && ${scriptPath}`)
42-
.then(({ stdout }) => console.log(stdout))
43-
.catch(console.error)
27+
.option(
28+
'--standaloneFolder',
29+
'Folder including NextJS standalone build. Parental folder should include more folders as well.',
30+
path.resolve(commandCwd, '.next/standalone'),
31+
)
32+
.option(
33+
'--publicFolder',
34+
'Folder where public assets are located, typically this folder is located in root of the project.',
35+
path.resolve(commandCwd, './public'),
36+
)
37+
.option(
38+
'--handlerPath',
39+
'Path to custom handler to be used to handle ApiGw events. By default this is provided for you.',
40+
path.resolve(scriptDir, './../server-handler/index.js'),
41+
)
42+
.option(
43+
'--outputFolder',
44+
'Path to folder which should be used for outputting bundled ZIP files for your Lambda. It will be cleared before every script run.',
45+
path.resolve(commandCwd, './next.out'),
46+
)
47+
.action(async (options) => {
48+
const { standaloneFolder, publicFolder, handlerPath, outputFolder } = options
49+
50+
// Dependencies layer configuration
51+
const nodeModulesFolderPath = path.resolve(standaloneFolder, './node_modules')
52+
const depsLambdaFolder = 'nodejs/node_modules'
53+
const lambdaNodeModulesPath = path.resolve('/opt', depsLambdaFolder)
54+
const dependenciesOutputPath = path.resolve(outputFolder, 'dependenciesLayer.zip')
55+
56+
// Code layer configuration
57+
const generatedNextServerPath = path.resolve(standaloneFolder, './server.js')
58+
const codeOutputPath = path.resolve(outputFolder, 'code.zip')
59+
60+
// Assets bundle configuration
61+
const generatedStaticContentPath = path.resolve(commandCwd, '.next/static')
62+
const generatedStaticRemapping = '_next/static'
63+
const assetsOutputPath = path.resolve(outputFolder, 'assetsLayer.zip')
64+
65+
// Clean output directory before continuing
66+
rmSync(outputFolder, { force: true, recursive: true })
67+
mkdirSync(outputFolder)
68+
69+
// Zip dependencies from standalone output in a layer-compatible format.
70+
await zipFolder({
71+
outputName: dependenciesOutputPath,
72+
folderPath: nodeModulesFolderPath,
73+
dir: depsLambdaFolder,
74+
})
75+
76+
// Zip staticly generated assets and public folder.
77+
await zipMultipleFoldersOrFiles({
78+
outputName: assetsOutputPath,
79+
inputDefinition: [
80+
{
81+
path: publicFolder,
82+
},
83+
{
84+
path: generatedStaticContentPath,
85+
dir: generatedStaticRemapping,
86+
},
87+
],
88+
})
89+
90+
// Create a symlink for node_modules so they point to the separately packaged layer.
91+
// We need to create symlink because we are not using NodejsFunction in CDK as bundling is custom.
92+
const tmpFolder = tmpdir()
93+
94+
const symlinkPath = path.resolve(tmpFolder, `./node_modules_${Math.random()}`)
95+
symlinkSync(lambdaNodeModulesPath, symlinkPath)
96+
97+
const nextConfig = findInFile(generatedNextServerPath, nextServerConfigRegex)
98+
const configPath = path.resolve(tmpFolder, `./config.json_${Math.random()}`)
99+
writeFileSync(configPath, nextConfig, 'utf-8')
100+
101+
// Zip codebase including symlinked node_modules and handler.
102+
await zipMultipleFoldersOrFiles({
103+
outputName: codeOutputPath,
104+
inputDefinition: [
105+
{
106+
isGlob: true,
107+
cwd: standaloneFolder,
108+
path: '**/*',
109+
ignore: ['**/node_modules/**', '*.zip'],
110+
},
111+
{
112+
isFile: true,
113+
path: handlerPath,
114+
name: 'handler.js',
115+
},
116+
{
117+
isFile: true,
118+
path: symlinkPath,
119+
name: 'node_modules',
120+
},
121+
{
122+
isFile: true,
123+
path: configPath,
124+
name: 'config.json',
125+
},
126+
],
127+
})
44128

45129
console.log('Your NextJS project was succefully prepared for Lambda.')
46130
})

Diff for: lib/image-handler.ts

+1-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
process.env.NODE_ENV = 'production'
2-
// Set NEXT_SHARP_PATH environment variable
31
// ! Make sure this comes before the fist import
42
process.env.NEXT_SHARP_PATH = require.resolve('sharp')
3+
process.env.NODE_ENV = 'production'
54

65
import type { APIGatewayProxyEventV2, APIGatewayProxyStructuredResultV2 } from 'aws-lambda'
76
import { defaultConfig, NextConfigComplete } from 'next/dist/server/config-shared'

Diff for: lib/server-handler.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
process.env.NODE_ENV = 'production'
1+
// ! This is needed for nextjs to correctly resolve.
22
process.chdir(__dirname)
3+
process.env.NODE_ENV = 'production'
34

45
import NextServer from 'next/dist/server/next-server'
56
import slsHttp from 'serverless-http'

Diff for: lib/utils.ts

+85-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3'
2+
import archiver from 'archiver'
3+
import { createWriteStream, readFileSync, symlinkSync } from 'fs'
4+
import { IOptions as GlobOptions } from 'glob'
25
import { IncomingMessage, ServerResponse } from 'http'
3-
import { replaceInFileSync } from 'replace-in-file'
46
import { NextUrlWithParsedQuery } from 'next/dist/server/request-meta'
7+
import { replaceInFileSync } from 'replace-in-file'
58
import { Readable } from 'stream'
69

710
// Make header keys lowercase to ensure integrity.
@@ -145,6 +148,7 @@ export const replaceVersionInCommonFiles = (oldVersion: string, newVersion: stri
145148
],
146149
files: [
147150
'package.json',
151+
'**/package.json', // Useful for workspaces with nested package.jsons also including versions.
148152
'package-lock.json',
149153
'package-lock.json', // Duplicate because lock file contains two occurences.
150154
// 'yarn.lock', Yarn3 lock file does not contain version from package.json
@@ -170,3 +174,83 @@ export const replaceVersionInCommonFiles = (oldVersion: string, newVersion: stri
170174

171175
return results
172176
}
177+
178+
export const findInFile = (filePath: string, regex: RegExp): string => {
179+
const content = readFileSync(filePath, 'utf-8')
180+
const data = content.match(regex)
181+
182+
if (!data?.[0]) {
183+
throw new Error('Unable to match Next server configuration.')
184+
}
185+
186+
return data[0]
187+
}
188+
189+
interface ZipFolderProps {
190+
outputName: string
191+
folderPath: string
192+
dir?: string
193+
}
194+
195+
export const zipFolder = async ({ folderPath, outputName, dir }: ZipFolderProps) =>
196+
zipMultipleFoldersOrFiles({
197+
outputName,
198+
inputDefinition: [{ path: folderPath, dir }],
199+
})
200+
201+
interface FolderInput {
202+
path: string
203+
dir?: string
204+
}
205+
206+
interface FileInput {
207+
path: string
208+
name: string
209+
isFile: true
210+
}
211+
212+
interface SymlinkInput {
213+
source: string
214+
target: string
215+
isSymlink: true
216+
}
217+
218+
interface GlobInput extends GlobOptions {
219+
path: string
220+
isGlob: true
221+
}
222+
223+
interface ZipProps {
224+
outputName: string
225+
inputDefinition: (FolderInput | FileInput | SymlinkInput | GlobInput)[]
226+
}
227+
228+
export const zipMultipleFoldersOrFiles = async ({ outputName, inputDefinition }: ZipProps) => {
229+
const archive = archiver('zip', { zlib: { level: 5 } })
230+
const stream = createWriteStream(outputName)
231+
232+
return new Promise((resolve, reject) => {
233+
inputDefinition.forEach((props) => {
234+
if ('isFile' in props) {
235+
archive.file(props.path, { name: props.name })
236+
} else if ('isSymlink' in props) {
237+
archive.symlink(props.source, props.target)
238+
} else if ('isGlob' in props) {
239+
archive.glob(props.path, props)
240+
} else {
241+
archive.directory(props.path, props.dir ?? false)
242+
}
243+
})
244+
245+
archive.on('error', (err) => reject(err)).pipe(stream)
246+
stream.on('close', resolve)
247+
archive.finalize()
248+
})
249+
}
250+
251+
interface SymlinkProps {
252+
sourcePath: string
253+
linkLocation: string
254+
}
255+
256+
export const createSymlink = ({ linkLocation, sourcePath }: SymlinkProps) => symlinkSync(sourcePath, linkLocation)

0 commit comments

Comments
 (0)