From 341eee293b772f0c71e667ccce4ebdca993513ff Mon Sep 17 00:00:00 2001 From: Hui Zhao <10602282+HuiSF@users.noreply.github.com> Date: Wed, 11 Dec 2024 15:20:06 -0800 Subject: [PATCH] feat(adapter-nextjs): support next.js 15 (#13947) * feat(adapter-nextjs): support next.js 15 * chore: run e2e tests aginst the changes * chore: publish next-15 tag * chore: enable next.js tests against v14 and v15 * chore: run e2e tests * chore: remove e2e run trigger for merging --- .github/integ-config/integ-all.yml | 22 ++-- .github/workflows/callable-e2e-test.yml | 6 +- .github/workflows/callable-e2e-tests.yml | 1 + .../__tests__/createServerRunner.test.ts | 4 +- ...torageAdapterFromNextServerContext.test.ts | 102 ++++++++++-------- packages/adapter-nextjs/package.json | 2 +- ...okieStorageAdapterFromNextServerContext.ts | 10 +- .../createRunWithAmplifyServerContext.ts | 2 +- 8 files changed, 89 insertions(+), 60 deletions(-) diff --git a/.github/integ-config/integ-all.yml b/.github/integ-config/integ-all.yml index b20a0cc45e1..4759997ee29 100644 --- a/.github/integ-config/integ-all.yml +++ b/.github/integ-config/integ-all.yml @@ -875,14 +875,24 @@ tests: # spec: duplicate-packages # browser: *minimal_browser_list - # SSR context isolation - - test_name: integ_ssr_context_isolation - desc: 'SSR Context Isolation' + # Next.js use cases + - test_name: integ_next-use-cases-14 + desc: 'Next.js use cases tests with v14' framework: next category: ssr-adapter - sample_name: ssr-context-isolation - spec: ssr-context-isolation - yarn_script: ci:ssr-context-isolation + sample_name: next-use-cases-14 + spec: next-use-cases + yarn_script: ci:next-use-cases-test + yarn_script_args: 14 + browser: [chrome] + - test_name: integ_next-use-cases-15 + desc: 'Next.js use cases tests with v15' + framework: next + category: ssr-adapter + sample_name: next-use-cases-15 + spec: next-use-cases + yarn_script: ci:next-use-cases-test + yarn_script_args: 15 browser: [chrome] - test_name: integ_next_mfa_req_email desc: 'mfa required with email sign in attribute' diff --git a/.github/workflows/callable-e2e-test.yml b/.github/workflows/callable-e2e-test.yml index ee02150baa3..a0bfd77d22f 100644 --- a/.github/workflows/callable-e2e-test.yml +++ b/.github/workflows/callable-e2e-test.yml @@ -37,6 +37,9 @@ on: yarn_script: required: false type: string + yarn_script_args: + required: false + type: number env: required: false type: string @@ -124,6 +127,7 @@ jobs: E2E_RETRY_COUNT: ${{ inputs.retry_count }} E2E_TEST_NAME: ${{ inputs.test_name }} E2E_YARN_SCRIPT: ${{ inputs.yarn_script }} + E2E_YARN_SCRIPT_ARGS: ${{ inputs.yarn_script_args }} E2E_ENV: ${{ inputs.env }} run: | if [ -z "$E2E_YARN_SCRIPT" ]; then @@ -141,7 +145,7 @@ jobs: $E2E_YARN_SCRIPT \ -n $E2E_RETRY_COUNT else - yarn "$E2E_YARN_SCRIPT" + yarn "$E2E_YARN_SCRIPT" "$E2E_YARN_SCRIPT_ARGS" fi - name: Upload artifact uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2 https://github.com/actions/upload-artifact/commit/0b7f8abb1508181956e8e162db84b466c27e18ce diff --git a/.github/workflows/callable-e2e-tests.yml b/.github/workflows/callable-e2e-tests.yml index 2b7604b1215..bdb174e32a7 100644 --- a/.github/workflows/callable-e2e-tests.yml +++ b/.github/workflows/callable-e2e-tests.yml @@ -44,6 +44,7 @@ jobs: timeout_minutes: ${{ matrix.integ-config.timeout_minutes || 35 }} retry_count: ${{ matrix.integ-config.retry_count || 3 }} yarn_script: ${{ matrix.integ-config.yarn_script || '' }} + yarn_script_args: ${{ matrix.integ-config.yarn_script_args || 15 }} env: ${{ matrix.integ-config.env && toJSON(matrix.integ-config.env) || '{}' }} # e2e-test-runner-headless: diff --git a/packages/adapter-nextjs/__tests__/createServerRunner.test.ts b/packages/adapter-nextjs/__tests__/createServerRunner.test.ts index 5dbaa9e0071..3509a4b49c3 100644 --- a/packages/adapter-nextjs/__tests__/createServerRunner.test.ts +++ b/packages/adapter-nextjs/__tests__/createServerRunner.test.ts @@ -124,7 +124,7 @@ describe('createServerRunner', () => { }); describe('when nextServerContext is not null', () => { - it('should create auth providers with cookie storage adapter', () => { + it('should create auth providers with cookie storage adapter', async () => { const operation = jest.fn(); const mockCookieStorageAdapter = { get: jest.fn(), @@ -147,7 +147,7 @@ describe('createServerRunner', () => { const { runWithAmplifyServerContext } = createServerRunner({ config: mockAmplifyConfig, }); - runWithAmplifyServerContext({ + await runWithAmplifyServerContext({ operation, nextServerContext: mockNextServerContext as unknown as NextServer.Context, diff --git a/packages/adapter-nextjs/__tests__/utils/createCookieStorageAdapterFromNextServerContext.test.ts b/packages/adapter-nextjs/__tests__/utils/createCookieStorageAdapterFromNextServerContext.test.ts index a42ec085e9c..c81383ea804 100644 --- a/packages/adapter-nextjs/__tests__/utils/createCookieStorageAdapterFromNextServerContext.test.ts +++ b/packages/adapter-nextjs/__tests__/utils/createCookieStorageAdapterFromNextServerContext.test.ts @@ -7,6 +7,7 @@ import { Socket } from 'net'; import { enableFetchMocks } from 'jest-fetch-mock'; import { NextRequest, NextResponse } from 'next/server.js'; import { cookies } from 'next/headers.js'; +import { CookieStorage } from 'aws-amplify/adapter-core'; import { DATE_IN_THE_PAST, @@ -46,29 +47,32 @@ describe('createCookieStorageAdapterFromNextServerContext', () => { describe('cookieStorageAdapter created from NextRequest and NextResponse', () => { const request = new NextRequest(new URL('https://example.com')); const response = NextResponse.next(); - - jest.spyOn(request, 'cookies', 'get').mockImplementation( - () => - ({ - get: mockGetFunc, - getAll: mockGetAllFunc, - }) as any, - ); - - jest.spyOn(response, 'cookies', 'get').mockImplementation(() => ({ - set: mockSetFunc, - delete: mockDeleteFunc, - get: jest.fn(), - getAll: jest.fn(), - has: jest.fn(), - })); - const mockContext = { request, response, } as any; + let result: CookieStorage.Adapter; + + beforeAll(async () => { + jest.spyOn(request, 'cookies', 'get').mockImplementation( + () => + ({ + get: mockGetFunc, + getAll: mockGetAllFunc, + }) as any, + ); - const result = createCookieStorageAdapterFromNextServerContext(mockContext); + jest.spyOn(response, 'cookies', 'get').mockImplementation(() => ({ + set: mockSetFunc, + delete: mockDeleteFunc, + get: jest.fn(), + getAll: jest.fn(), + has: jest.fn(), + })); + + result = + await createCookieStorageAdapterFromNextServerContext(mockContext); + }); it('gets cookie by calling `get` method of the underlying cookie store', () => { result.get(mockKey); @@ -121,26 +125,32 @@ describe('createCookieStorageAdapterFromNextServerContext', () => { describe('cookieStorageAdapter created from NextRequest and Response', () => { const request = new NextRequest(new URL('https://example.com')); const response = new Response(); - - jest.spyOn(request, 'cookies', 'get').mockImplementation( - () => - ({ - get: mockGetFunc, - getAll: mockGetAllFunc, - }) as any, - ); - jest.spyOn(response, 'headers', 'get').mockImplementation( - () => - ({ - append: mockAppend, - }) as any, - ); - const mockContext = { request, response, } as any; + let result: CookieStorage.Adapter; + + beforeAll(async () => { + jest.spyOn(request, 'cookies', 'get').mockImplementation( + () => + ({ + get: mockGetFunc, + getAll: mockGetAllFunc, + }) as any, + ); + jest.spyOn(response, 'headers', 'get').mockImplementation( + () => + ({ + append: mockAppend, + }) as any, + ); + + result = + await createCookieStorageAdapterFromNextServerContext(mockContext); + }); + const mockSerializeOptions = { domain: 'example.com', expires: new Date('2023-08-22'), @@ -150,8 +160,6 @@ describe('createCookieStorageAdapterFromNextServerContext', () => { path: '/a-path', }; - const result = createCookieStorageAdapterFromNextServerContext(mockContext); - it('gets cookie by calling `get` method of the underlying cookie store', () => { result.get(mockKey); expect(mockGetFunc).toHaveBeenCalledWith(mockKey); @@ -233,9 +241,15 @@ describe('createCookieStorageAdapterFromNextServerContext', () => { }); describe('cookieStorageAdapter created from Next cookies function', () => { - mockNextCookiesFunc.mockReturnValueOnce(mockNextCookiesFuncReturn); + let result: CookieStorage.Adapter; + + beforeAll(async () => { + mockNextCookiesFunc.mockReturnValueOnce(mockNextCookiesFuncReturn); - const result = createCookieStorageAdapterFromNextServerContext({ cookies }); + result = await createCookieStorageAdapterFromNextServerContext({ + cookies, + }); + }); it('gets cookie by calling `get` method of the underlying cookie store', () => { result.get(mockKey); @@ -286,7 +300,7 @@ describe('createCookieStorageAdapterFromNextServerContext', () => { }); describe('cookieStorageAdapter created from IncomingMessage and ServerResponse as the Pages Router context', () => { - it('operates with the underlying cookie store', () => { + it('operates with the underlying cookie store', async () => { const mockCookies = { key1: 'value1', key2: 'value2', @@ -302,7 +316,7 @@ describe('createCookieStorageAdapterFromNextServerContext', () => { }, }); - const result = createCookieStorageAdapterFromNextServerContext({ + const result = await createCookieStorageAdapterFromNextServerContext({ request: request as any, response, }); @@ -341,7 +355,7 @@ describe('createCookieStorageAdapterFromNextServerContext', () => { ]); }); - it('operates with the underlying cookie store with encoded cookie names', () => { + it('operates with the underlying cookie store with encoded cookie names', async () => { // these the auth keys generated by Amplify const encodedCookieName1 = encodeURIComponent('test@email.com.idToken'); const encodedCookieName2 = encodeURIComponent( @@ -364,7 +378,7 @@ describe('createCookieStorageAdapterFromNextServerContext', () => { }, }); - const result = createCookieStorageAdapterFromNextServerContext({ + const result = await createCookieStorageAdapterFromNextServerContext({ request: request as any, response, }); @@ -413,7 +427,7 @@ describe('createCookieStorageAdapterFromNextServerContext', () => { ]); }); - it('does not add duplicate cookies when the cookies are defined in the response Set-Cookie headers', () => { + it('does not add duplicate cookies when the cookies are defined in the response Set-Cookie headers', async () => { const mockExistingSetCookieValues = [ 'CognitoIdentityServiceProvider.1234.accessToken=1234;Path=/', 'CognitoIdentityServiceProvider.1234.refreshToken=1234;Path=/', @@ -433,7 +447,7 @@ describe('createCookieStorageAdapterFromNextServerContext', () => { getHeaderSpy.mockReturnValue(mockExistingSetCookieValues); - const result = createCookieStorageAdapterFromNextServerContext({ + const result = await createCookieStorageAdapterFromNextServerContext({ request: request as any, response, }); @@ -455,6 +469,6 @@ describe('createCookieStorageAdapterFromNextServerContext', () => { request: undefined, response: new ServerResponse({} as any), } as any), - ).toThrow(); + ).rejects.toThrow(); }); }); diff --git a/packages/adapter-nextjs/package.json b/packages/adapter-nextjs/package.json index e56ca3d05c0..8c00508ecfd 100644 --- a/packages/adapter-nextjs/package.json +++ b/packages/adapter-nextjs/package.json @@ -5,7 +5,7 @@ "description": "The adapter for the supporting of using Amplify APIs in Next.js.", "peerDependencies": { "aws-amplify": "^6.0.7", - "next": ">=13.5.0 <15.0.0" + "next": ">=13.5.0 <16.0.0" }, "dependencies": { "aws-jwt-verify": "^4.0.1", diff --git a/packages/adapter-nextjs/src/utils/createCookieStorageAdapterFromNextServerContext.ts b/packages/adapter-nextjs/src/utils/createCookieStorageAdapterFromNextServerContext.ts index e3f99cbf96c..c36776d3ad1 100644 --- a/packages/adapter-nextjs/src/utils/createCookieStorageAdapterFromNextServerContext.ts +++ b/packages/adapter-nextjs/src/utils/createCookieStorageAdapterFromNextServerContext.ts @@ -11,9 +11,9 @@ import { NextServer } from '../types'; export const DATE_IN_THE_PAST = new Date(0); -export const createCookieStorageAdapterFromNextServerContext = ( +export const createCookieStorageAdapterFromNextServerContext = async ( context: NextServer.Context, -): CookieStorage.Adapter => { +): Promise => { const { request: req, response: res } = context as Partial; @@ -110,10 +110,10 @@ const createCookieStorageAdapterFromNextRequestAndHttpResponse = ( }; }; -const createCookieStorageAdapterFromNextCookies = ( +const createCookieStorageAdapterFromNextCookies = async ( cookies: NextServer.ServerComponentContext['cookies'], -): CookieStorage.Adapter => { - const cookieStore = cookies(); +): Promise => { + const cookieStore = await cookies(); // When Next cookies() is called in a server component, it returns a readonly // cookie store. Hence calling set and delete throws an error. However, diff --git a/packages/adapter-nextjs/src/utils/createRunWithAmplifyServerContext.ts b/packages/adapter-nextjs/src/utils/createRunWithAmplifyServerContext.ts index 3d20f19cd67..497f56f33dc 100644 --- a/packages/adapter-nextjs/src/utils/createRunWithAmplifyServerContext.ts +++ b/packages/adapter-nextjs/src/utils/createRunWithAmplifyServerContext.ts @@ -32,7 +32,7 @@ export const createRunWithAmplifyServerContext = ({ nextServerContext === null ? sharedInMemoryStorage : createKeyValueStorageFromCookieStorageAdapter( - createCookieStorageAdapterFromNextServerContext( + await createCookieStorageAdapterFromNextServerContext( nextServerContext, ), createTokenValidator({