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
3 changes: 2 additions & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
},
"plugins": [
"@typescript-eslint",
"prettier"
"prettier",
"jest"
],
"rules": {
"semi": ["error", "never"],
Expand Down
6 changes: 6 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
modulePathIgnorePatterns: ['<rootDir>/dist/']
}
10,491 changes: 6,402 additions & 4,089 deletions package-lock.json

Large diffs are not rendered by default.

9 changes: 6 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"deployment"
],
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"test": "jest",
"build": "tsc",
"lint": "npx eslint \"./src/**/*.ts\"",
"lint:fix": "eslint \"src/**/*.ts*\" --fix",
Expand All @@ -42,6 +42,7 @@
"@semantic-release/git": "10.0.1",
"@semantic-release/npm": "12.0.1",
"@types/aws-lambda": "8.10.138",
"@types/jest": "29.5.14",
"@types/lodash": "4.17.13",
"@types/node": "20.12.11",
"@types/yargs": "17.0.32",
Expand All @@ -50,11 +51,14 @@
"conventional-changelog-conventionalcommits": "8.0.0",
"eslint": "8.57.0",
"eslint-config-prettier": "9.1.0",
"eslint-plugin-jest": "28.9.0",
"eslint-plugin-prettier": "5.1.3",
"husky": "9.1.4",
"jest": "29.7.0",
"lint-staged": "15.2.8",
"prettier": "3.2.5",
"semantic-release": "24.0.0",
"ts-jest": "29.2.5",
"typescript": "5.4.5"
},
"dependencies": {
Expand All @@ -66,8 +70,7 @@
"@aws-sdk/client-sts": "3.590.0",
"@aws-sdk/credential-providers": "3.590.0",
"@aws-sdk/util-endpoints": "3.587.0",
"@dbbs/next-cache-handler-core": "1.2.0",
"@dbbs/next-cache-handler-s3": "1.2.0",
"@dbbs/next-cache-handler-core": "1.3.0",
"aws-cdk-lib": "2.144.0",
"aws-sdk": "2.1635.0",
"cdk-assets": "2.144.0",
Expand Down
18 changes: 8 additions & 10 deletions src/cacheHandler/index.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,17 @@
import { Cache } from '@dbbs/next-cache-handler-core'
import { S3Cache } from '@dbbs/next-cache-handler-s3'
import getConfig from 'next/config'
import { CacheConfig } from '../types'
import { S3Cache } from './strategy/s3'

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

Cache.addCookies(config?.cacheCookies ?? [])
Cache.addQueries(config?.cacheQueries ?? [])
Cache.addNoCacheMatchers(config?.noCacheRoutes ?? [])

if (config?.enableDeviceSplit) {
Cache.addDeviceSplit()
}

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

export default Cache
181 changes: 181 additions & 0 deletions src/cacheHandler/strategy/s3.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { CacheEntry, CacheContext } from '@dbbs/next-cache-handler-core'
import { S3Cache } from './s3'

const mockHtmlPage = '<p>My Page</p>'

export const mockCacheEntry = {
value: {
pageData: {},
html: mockHtmlPage,
kind: 'PAGE',
postponed: undefined,
headers: undefined,
status: 200
},
lastModified: 100000
} satisfies CacheEntry

const mockCacheContext: CacheContext = {
isAppRouter: false,
serverCacheDirPath: ''
}

const mockBucketName = 'test-bucket'
const cacheKey = 'test'
const s3Cache = new S3Cache(mockBucketName)

const store = new Map()
const mockGetObject = jest.fn().mockImplementation(async ({ Key }) => {
const res = store.get(Key)
return res
? { Body: { transformToString: () => res.Body }, Metadata: res.Metadata }
: { Body: undefined, Metadata: undefined }
})
const mockPutObject = jest
.fn()
.mockImplementation(async ({ Key, Body, Metadata }) => store.set(Key, { Body, Metadata }))
const mockDeleteObject = jest.fn().mockImplementation(async ({ Key }) => store.delete(Key))
const mockDeleteObjects = jest
.fn()
.mockImplementation(async ({ Delete: { Objects } }: { Delete: { Objects: { Key: string }[] } }) =>
Objects.forEach(({ Key }) => store.delete(Key))
)
const mockGetObjectList = jest
.fn()
.mockImplementation(async () => ({ Contents: [...store.keys()].map((key) => ({ Key: key })) }))
const mockGetObjectTagging = jest
.fn()
.mockImplementation(() => ({ TagSet: [{ Key: 'revalidateTag0', Value: cacheKey }] }))

jest.mock('@aws-sdk/client-s3', () => {
return {
S3: jest.fn().mockReturnValue({
getObject: jest.fn((...params) => mockGetObject(...params)),
putObject: jest.fn((...params) => mockPutObject(...params)),
deleteObject: jest.fn((...params) => mockDeleteObject(...params)),
deleteObjects: jest.fn((...params) => mockDeleteObjects(...params)),
listObjectsV2: jest.fn((...params) => mockGetObjectList(...params)),
getObjectTagging: jest.fn((...params) => mockGetObjectTagging(...params)),
config: {}
})
}
})

describe('S3Cache', () => {
afterEach(() => {
jest.clearAllMocks()
})
afterAll(() => {
jest.restoreAllMocks()
})

it('should set and read the cache for page router', async () => {
await s3Cache.set(cacheKey, cacheKey, mockCacheEntry, mockCacheContext)
expect(s3Cache.client.putObject).toHaveBeenCalledTimes(2)
expect(s3Cache.client.putObject).toHaveBeenNthCalledWith(1, {
Bucket: mockBucketName,
Key: `${cacheKey}/${cacheKey}.html`,
Body: mockHtmlPage,
ContentType: 'text/html'
})
expect(s3Cache.client.putObject).toHaveBeenNthCalledWith(2, {
Bucket: mockBucketName,
Key: `${cacheKey}/${cacheKey}.json`,
Body: JSON.stringify(mockCacheEntry.value.pageData),
ContentType: 'application/json'
})

const result = await s3Cache.get(cacheKey, cacheKey, mockCacheContext)
expect(result).toEqual(mockCacheEntry.value.pageData)
expect(s3Cache.client.getObject).toHaveBeenCalledTimes(1)
expect(s3Cache.client.getObject).toHaveBeenCalledWith({
Bucket: mockBucketName,
Key: `${cacheKey}/${cacheKey}.json`
})
})

it('should set and read the cache for app router', async () => {
await s3Cache.set(cacheKey, cacheKey, mockCacheEntry, { ...mockCacheContext, isAppRouter: true })
expect(s3Cache.client.putObject).toHaveBeenCalledTimes(2)
expect(s3Cache.client.putObject).toHaveBeenNthCalledWith(1, {
Bucket: mockBucketName,
Key: `${cacheKey}/${cacheKey}.html`,
Body: mockHtmlPage,
ContentType: 'text/html'
})
expect(s3Cache.client.putObject).toHaveBeenNthCalledWith(2, {
Bucket: mockBucketName,
Key: `${cacheKey}/${cacheKey}.rsc`,
Body: mockCacheEntry.value.pageData,
ContentType: 'text/x-component'
})

const result = await s3Cache.get(cacheKey, cacheKey, mockCacheContext)
expect(result).toEqual(mockCacheEntry.value.pageData)
expect(s3Cache.client.getObject).toHaveBeenCalledTimes(1)
expect(s3Cache.client.getObject).toHaveBeenCalledWith({
Bucket: mockBucketName,
Key: `${cacheKey}/${cacheKey}.json`
})
})

it('should delete cache value', async () => {
await s3Cache.set(cacheKey, cacheKey, mockCacheEntry, mockCacheContext)
expect(s3Cache.client.putObject).toHaveBeenCalledTimes(2)
expect(s3Cache.client.putObject).toHaveBeenNthCalledWith(1, {
Bucket: mockBucketName,
Key: `${cacheKey}/${cacheKey}.html`,
Body: mockHtmlPage,
ContentType: 'text/html'
})
expect(s3Cache.client.putObject).toHaveBeenNthCalledWith(2, {
Bucket: mockBucketName,
Key: `${cacheKey}/${cacheKey}.json`,
Body: JSON.stringify(mockCacheEntry.value.pageData),
ContentType: 'application/json'
})

const result = await s3Cache.get(cacheKey, cacheKey, mockCacheContext)
expect(result).toEqual(mockCacheEntry.value.pageData)
expect(s3Cache.client.getObject).toHaveBeenCalledTimes(1)
expect(s3Cache.client.getObject).toHaveBeenCalledWith({
Bucket: mockBucketName,
Key: `${cacheKey}/${cacheKey}.json`
})

await s3Cache.delete(cacheKey, cacheKey)
const updatedResult = await s3Cache.get(cacheKey, cacheKey, mockCacheContext)
expect(updatedResult).toBeNull()
expect(s3Cache.client.deleteObjects).toHaveBeenCalledTimes(1)
expect(s3Cache.client.deleteObjects).toHaveBeenNthCalledWith(1, {
Bucket: mockBucketName,
Delete: {
Objects: [
{ Key: `${cacheKey}/${cacheKey}.json` },
{ Key: `${cacheKey}/${cacheKey}.html` },
{ Key: `${cacheKey}/${cacheKey}.rsc` }
]
}
})
})

it('should revalidate cache by tag', async () => {
const mockCacheEntryWithTags = { ...mockCacheEntry, tags: [cacheKey] }
await s3Cache.set(cacheKey, cacheKey, mockCacheEntryWithTags, mockCacheContext)

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

await s3Cache.revalidateTag(cacheKey, [])

expect(await s3Cache.get(cacheKey, cacheKey, mockCacheContext)).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)

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