Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 1 addition & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,7 @@
"description": "",
"main": "./dist/index.js",
"bin": {
"@dbbs/next-serverless-deployment": "./dist/index.js"
},
"exports": {
"./*": "./dist/*"
"@dbbs/next-serverless-deployment": "./dist/commands/index.js"
},
"types": "dist/types/index.d.ts",
"files": [
Expand Down
71 changes: 23 additions & 48 deletions src/build/next.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import childProcess from 'node:child_process'
import fs from 'fs/promises'
import path from 'node:path'
import { type ProjectPackager, type ProjectSettings, loadFile } from '../common/project'
import loadConfig from '../commands/helpers/loadConfig'
import appRouterRevalidate from './cache/handlers/appRouterRevalidate'
import { type ProjectPackager, type ProjectSettings } from '../common/project'
import appRouterRevalidateTemplate from './cache/handlers/appRouterRevalidate'

interface BuildOptions {
packager: ProjectPackager
Expand All @@ -21,78 +20,52 @@ interface BuildAppOptions {

export const OUTPUT_FOLDER = 'serverless-next'

const setNextOptions = async (nextConfigPath: string, s3BucketName: string): Promise<() => Promise<void>> => {
// set s3 bucket name for cache handler during build time
const setNextEnvs = (s3BucketName: string) => {
process.env.STATIC_BUCKET_NAME = s3BucketName

const cacheConfig = await loadConfig()
const currentConfig = await loadFile(nextConfigPath)
const updatedConfig = {
...currentConfig,
output: 'standalone',
serverRuntimeConfig: {
...currentConfig.serverRuntimeConfig,
nextServerlessCacheConfig: cacheConfig
},
cacheHandler: require.resolve(path.join('..', 'cacheHandler', 'index.js'))
}

const currentContent = await fs.readFile(nextConfigPath, 'utf-8')

let updatedContent = `module.exports = ${JSON.stringify(updatedConfig, null, 4)};\n`

// Check if the file has .mjs extension
if (nextConfigPath.endsWith('.mjs')) {
updatedContent = `export default ${JSON.stringify(updatedConfig, null, 4)};\n`
}

await fs.writeFile(nextConfigPath, updatedContent, 'utf-8')

// Function to revert back to original content of file
return async () => {
fs.writeFile(nextConfigPath, currentContent, 'utf-8')
}
process.env.NEXT_SERVERLESS_DEPLOYING_PHASE = 'true'
}

const appendRevalidateApi = async (projectPath: string, isAppDir: boolean): Promise<string> => {
const routeFolderPath = path.join(projectPath, isAppDir ? 'src/app' : 'src', 'api', 'revalidate')
const routePath = path.join(routeFolderPath, 'route.ts')
if ((await fs.stat(routeFolderPath)).isDirectory()) {
await fs.mkdir(routeFolderPath, { recursive: true })
}

fs.writeFile(routePath, appRouterRevalidate, 'utf-8')
await fs.mkdir(routeFolderPath, { recursive: true })
await fs.writeFile(routePath, appRouterRevalidateTemplate, 'utf-8')

return routePath
}

export const buildNext = async (options: BuildOptions): Promise<() => Promise<void>> => {
const { packager, nextConfigPath, s3BucketName, projectPath, isAppDir } = options
const { packager, projectPath, s3BucketName, isAppDir } = options

setNextEnvs(s3BucketName)
const revalidateRoutePath = await appendRevalidateApi(projectPath, isAppDir)
const clearNextConfig = await setNextOptions(nextConfigPath, s3BucketName)
childProcess.execSync(packager.buildCommand, { stdio: 'inherit' })

// Reverts changes to the next project
return async () => {
await Promise.all([clearNextConfig(), fs.rm(revalidateRoutePath)])
await fs.rm(revalidateRoutePath)
}
}

const copyAssets = async (outputPath: string, appPath: string) => {
const copyAssets = async (outputPath: string, appPath: string, appRelativePath: string) => {
// Copying static assets (like js, css, images, .etc)
await Promise.all([
fs.cp(path.join(appPath, '.next', 'static'), path.join(outputPath, '_next', 'static'), { recursive: true }),
fs.cp(path.join(appPath, '.next', 'standalone'), path.join(outputPath, 'server'), {
await fs.cp(path.join(appPath, '.next'), path.join(outputPath, '.next'), {
recursive: true
})
await fs.cp(
path.join(appPath, '.next', 'static'),
path.join(outputPath, '.next', 'standalone', appRelativePath, '.next', 'static'),
{
recursive: true
})
])
}
)
}

export const buildApp = async (options: BuildAppOptions) => {
const { projectSettings, outputPath, s3BucketName } = options

const { packager, nextConfigPath, projectPath, isAppDir } = projectSettings
const { packager, nextConfigPath, projectPath, isAppDir, root, isMonorepo } = projectSettings

const cleanNextApp = await buildNext({
packager,
Expand All @@ -102,7 +75,9 @@ export const buildApp = async (options: BuildAppOptions) => {
projectPath
})

await copyAssets(outputPath, projectPath)
const appRelativePath = isMonorepo ? path.relative(root, projectPath) : ''

await copyAssets(outputPath, projectPath, appRelativePath)

return cleanNextApp
}
21 changes: 21 additions & 0 deletions src/build/withNextDeploy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { NextConfig } from 'next/dist/server/config-shared'
import path from 'node:path'
import loadConfig from '../commands/helpers/loadConfig'

export const withNextDeploy = async (nextConfig: NextConfig): Promise<NextConfig> => {
if (process.env.NEXT_SERVERLESS_DEPLOYING_PHASE === 'true') {
const cacheConfig = await loadConfig()
return {
...nextConfig,
output: 'standalone',
serverRuntimeConfig: {
...nextConfig.serverRuntimeConfig,
nextServerlessCacheConfig: cacheConfig,
staticBucketName: process.env.STATIC_BUCKET_NAME
},
cacheHandler: require.resolve(path.join('..', 'cacheHandler', 'index.js'))
}
}

return nextConfig
}
3 changes: 2 additions & 1 deletion src/cacheHandler/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@ import { S3Cache } from './strategy/s3'

const { serverRuntimeConfig } = getConfig() || {}
const config: CacheConfig | undefined = serverRuntimeConfig?.nextServerlessCacheConfig
const staticBucketName = serverRuntimeConfig?.staticBucketName || ''

Cache.setConfig({
cacheCookies: config?.cacheCookies ?? [],
cacheQueries: config?.cacheQueries ?? [],
noCacheMatchers: config?.noCacheRoutes ?? [],
enableDeviceSplit: config?.enableDeviceSplit,
cache: new S3Cache(process.env.STATIC_BUCKET_NAME!)
cache: new S3Cache(staticBucketName)
})

export default Cache
16 changes: 8 additions & 8 deletions src/cacheHandler/strategy/s3.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ describe('S3Cache', () => {
ContentType: 'application/json'
})

const result = await s3Cache.get(cacheKey, cacheKey, mockCacheContext)
const result = await s3Cache.get(cacheKey, cacheKey)
expect(result).toEqual(mockCacheEntry.value.pageData)
expect(s3Cache.client.getObject).toHaveBeenCalledTimes(1)
expect(s3Cache.client.getObject).toHaveBeenCalledWith({
Expand All @@ -110,7 +110,7 @@ describe('S3Cache', () => {
ContentType: 'text/x-component'
})

const result = await s3Cache.get(cacheKey, cacheKey, mockCacheContext)
const result = await s3Cache.get(cacheKey, cacheKey)
expect(result).toEqual(mockCacheEntry.value.pageData)
expect(s3Cache.client.getObject).toHaveBeenCalledTimes(1)
expect(s3Cache.client.getObject).toHaveBeenCalledWith({
Expand All @@ -135,7 +135,7 @@ describe('S3Cache', () => {
ContentType: 'application/json'
})

const result = await s3Cache.get(cacheKey, cacheKey, mockCacheContext)
const result = await s3Cache.get(cacheKey, cacheKey)
expect(result).toEqual(mockCacheEntry.value.pageData)
expect(s3Cache.client.getObject).toHaveBeenCalledTimes(1)
expect(s3Cache.client.getObject).toHaveBeenCalledWith({
Expand All @@ -144,7 +144,7 @@ describe('S3Cache', () => {
})

await s3Cache.delete(cacheKey, cacheKey)
const updatedResult = await s3Cache.get(cacheKey, cacheKey, mockCacheContext)
const updatedResult = await s3Cache.get(cacheKey, cacheKey)
expect(updatedResult).toBeNull()
expect(s3Cache.client.deleteObjects).toHaveBeenCalledTimes(1)
expect(s3Cache.client.deleteObjects).toHaveBeenNthCalledWith(1, {
Expand All @@ -163,19 +163,19 @@ describe('S3Cache', () => {
const mockCacheEntryWithTags = { ...mockCacheEntry, tags: [cacheKey] }
await s3Cache.set(cacheKey, cacheKey, mockCacheEntryWithTags, mockCacheContext)

expect(await s3Cache.get(cacheKey, cacheKey, mockCacheContext)).toEqual(mockCacheEntryWithTags.value.pageData)
expect(await s3Cache.get(cacheKey, cacheKey)).toEqual(mockCacheEntryWithTags.value.pageData)

await s3Cache.revalidateTag(cacheKey, [])

expect(await s3Cache.get(cacheKey, cacheKey, mockCacheContext)).toBeNull()
expect(await s3Cache.get(cacheKey, cacheKey)).toBeNull()
})

it('should revalidate cache by path', async () => {
await s3Cache.set(cacheKey, cacheKey, mockCacheEntry, mockCacheContext)

expect(await s3Cache.get(cacheKey, cacheKey, mockCacheContext)).toEqual(mockCacheEntry.value.pageData)
expect(await s3Cache.get(cacheKey, cacheKey)).toEqual(mockCacheEntry.value.pageData)

await s3Cache.deleteAllByKeyMatch(cacheKey, '')
expect(await s3Cache.get(cacheKey, cacheKey, mockCacheContext)).toBeNull()
expect(await s3Cache.get(cacheKey, cacheKey)).toBeNull()
})
})
15 changes: 9 additions & 6 deletions src/commands/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,21 +168,24 @@ export const deploy = async (config: DeployConfig) => {
const versionLabel = `${OUTPUT_FOLDER}-server-v${now}`

fs.writeFileSync(
path.join(outputPath, 'server', 'Procfile'),
path.join(outputPath, '.next', 'Procfile'),
`web: node ${path.join(path.relative(projectSettings.root, projectSettings.projectPath), 'server.js')}`
)

childProcess.execSync(`cd ${path.join(outputPath, 'server')} && zip -r ../${archivedFolderName} \\.* *`, {
stdio: 'inherit'
})
childProcess.execSync(
`cd ${path.join(outputPath, '.next', 'standalone')} && zip -r ../../${archivedFolderName} \\.* *`,
{
stdio: 'inherit'
}
)

// prune static bucket before upload
await emptyBucket(s3Client, nextRenderServerStackOutput.StaticBucketName)

await uploadFolderToS3(s3Client, {
Bucket: nextRenderServerStackOutput.StaticBucketName,
Key: '_next',
folderRootPath: outputPath
Key: '_next/static',
folderRootPath: path.join(outputPath, '.next', 'static')
})

// upload code version to bucket.
Expand Down
103 changes: 103 additions & 0 deletions src/commands/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
#!/usr/bin/env node
import yargs from 'yargs'
import { hideBin } from 'yargs/helpers'
import { deploy } from './deploy'
import { bootstrap } from './bootstrap'

interface CLIOptions {
siteName: string
stage?: string
region?: string
profile?: string
nodejs?: string
production?: boolean
renderServerInstanceType?: string
renderServerMinInstances?: number
renderServerMaxInstances?: number
}

const cli = yargs(hideBin(process.argv))
.scriptName('@dbbs-next')
.usage('$0 <command> [options]')
.option('region', {
type: 'string'
})
.option('profile', {
type: 'string'
})

cli.command<CLIOptions>(
'bootstrap',
'bootsrap CDK project',
() => {},
async (argv) => {
const { profile, region } = argv
await bootstrap({ profile, region })
}
)

cli
.command<CLIOptions>(
'deploy',
'app deployment',
() => {},
async (argv) => {
const {
siteName,
stage,
region,
profile,
nodejs,
production,
renderServerInstanceType,
renderServerMinInstances,
renderServerMaxInstances
} = argv

await deploy({
siteName,
stage,
nodejs,
isProduction: production,
renderServerInstanceType,
renderServerMinInstances,
renderServerMaxInstances,
aws: {
region,
profile
}
})
}
)
.option('siteName', {
type: 'string',
requiresArg: true,
describe: 'The name is used to create CDK stack and components.'
})
.option('stage', {
type: 'string',
describe: 'The stage of the app, defaults to production'
})
.option('nodejs', {
type: 'string'
})
.option('production', {
type: 'boolean',
description: 'Creates production stack.',
default: false
})
.option('renderServerInstanceType', {
type: 'string',
describe: 'Set instance type for render server. Default is t2.micro.'
})
.option('renderServerMinInstances', {
type: 'number',
describe: 'Set min render server instances. Default is 1.'
})
.option('renderServerMaxInstances', {
type: 'number',
describe: 'Set max render server instances. Default is 2.'
})

cli.help()
cli.parse()
6 changes: 3 additions & 3 deletions src/common/aws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,17 +69,17 @@ export const uploadFileToS3 = async (s3Client: S3, options: PutObjectCommandInpu

export const uploadFolderToS3 = async (s3Client: S3, options: S3UploadFolderOptions) => {
const { folderRootPath, Key, ...s3UploadOptions } = options
const files = fs.readdirSync(path.join(folderRootPath, Key))
const files = fs.readdirSync(folderRootPath)

for (const file of files) {
const filePath = path.join(folderRootPath, Key, file)
const filePath = path.join(folderRootPath, file)
const s3FilePath = path.join(Key, file)

if (fs.lstatSync(filePath).isDirectory()) {
await uploadFolderToS3(s3Client, {
...s3UploadOptions,
Key: s3FilePath,
folderRootPath
folderRootPath: filePath
})
} else {
await uploadFileToS3(s3Client, {
Expand Down
Loading