aws-amplify の SSR (Nuxt3) 対応についての補足

SCSKの畑です。10回目の投稿です。

今回は6回目のエントリで記載した、aws-amplify のSSR (Nuxt3)対応の詳細もとい補足について記載します。小ネタです。

対応の背景などは同エントリに記載しているのでここでは省略します。

 

aws-amplify 用のコンフィグファイル実装

aws-amplify モジュールを使用するにあたり、AppSync や Cognito など、モジュールが使用する各種サービスの情報が必要となります。言い換えると、Amplify.configure の引数としてどのような内容を設定するべきか、です。

ところが、具体的にどのように設定すべきなのかというドキュメントや情報が相対的に乏しく、あったとしても aws-amplify のバージョンが異なっておりコピペだと動かないなど、ちゃんと動かすまで結構苦労したため備忘として残しておこうと思います。例えば、GraphQL のデフォルト認証モードが IAM な場合は値を 「iam」に設定するみたいな情報とか、少なくとも実装時点ではちょっと調べた程度だと出てこなかったりしたんですよね。公式ドキュメントでは以下 URL が対応すると思うのですが、一覧としての記載はありませんし。。

Configure Amplify categories - Vue - AWS Amplify Gen 1 Documentation
Configuring the client. AWS Amplify Documentation

色々と調べた限り、手動でコンフィグファイルを作成するより、Amplify CLI で経由で各種リソースを構成した際に生成されるコンフィグファイル (src/aws-exports や src/amplifyconfiguration.json) を使用するのが事例としては多いように思えました。そのことも、手動でコンフィグファイルを作成する情報が相対的に少ない原因に繋がっているように思えます。

ただ、今回は別エントリで説明した通りお客さん環境で Amplify を使用できないことが分かっていたこと、Amplify 経由で他サービスを構成した際の細かい設定が難しいことの2点より、Amplify 経由で構成したのは AppSync/DynamoDB のみでした。コンフィグファイル自体も最初から手動で作成する方針で進めていたので、その分時間もかかったような恰好です。

そもそも、コンフィグファイルの書式からして異なっているというのも、構築当初はかなり混乱しました。書式を参考にしようとしても参考にならず、しかしながら Amplify.configure の引数としてはどちらも使用できるというのが・・

What options can I pass to Amplify.configure()?
There seem to be two different ways to configure Amplify. Some examples use a key-value properties format:import aws_exp...

実際、aws-amplify モジュールは UI 含めて色々と便利で、このモジュールだけを使用したいというケースも全然考えられると思うので、コンフィグ定義一覧程度は公式ドキュメントに載せてくれるとありがたいですね。

ということで、今回の場合の実装例を示してみます。対象は Amplify(GraphQL) 及び Cognito の2種類です。

{
    "Auth": {
        "Cognito": {
            "region": "ap-northeast-1",
            "userPoolId": "<Cognito_UserPool_Id>",
            "userPoolClientId": "<Cognito_Application_Client_Id>",
            "identityPoolId": "<Cognito_IdentityPool_Id>"
        }
    },
    "API": {
        "GraphQL": {
            "endpoint": "<AppSync_GraphQL_Endpoint_URL>",
            "region": "ap-northeast-1",
            "defaultAuthMode": "iam"
        }
    }
}

なお、他サービスを含めたより詳細な内容については、以下広野さんのエントリも合わせてご参照ください。私も最初にこのエントリを見ていさえすれば、試行錯誤に数時間を費やすところまではいかなかったでしょう・・

aws-amplify と @aws-amplify/ui-react モジュールを v6 にアップデートしてみた
aws-amplify と @aws-amplify/ui-react モジュールをバージョン 5 から 6 にアップグレードしたときの対応を紹介します。

 

Plugin 実装

こちらは、基本的には公式 URL の内容をほぼ踏襲して実装しています。

Use Amplify categories APIs from Nuxt 3 - Next.js - AWS Amplify Gen 1 Documentation
Use Amplify categories APIs from Nuxt 3 AWS Amplify Documentation

なお、Plugin のファイル名で、client/server のどちらで実行するプラグインかどうかを指定しています。また、Plugin の読込み順序はファイル名(昇順)に従うため、ファイル名の先頭にナンバリングを付与して一番最初に読み込まれるようにしています。前回のエントリで記載した通り、他の Plugin 内で aws-amplify/auth (Cognito) や aws-amplify/data (AppSync/GraphQL) の機能を使用しているため、先にこちらの処理を実施しておく必要があるためです。公式 URL の実装例も正にそのようになっていますね。

 

01.amplifyApis.client.ts

client 側で実行する plugin です。先般のエントリで記載した通り、aws-amplify 自体は client 側で実行する前提のモジュールとなっているため、実装内容自体はシンプルです。

今回のアプリケーションで使用するのは Auth/GraphQL の2種類のみであるため、その中から使用する機能やメソッドのみを返すようにしています。また、先に説明した aws-amplify 用の設定ファイルを Amplify.configure の引数で指定しています。

import { Amplify } from 'aws-amplify';
import { fetchAuthSession, fetchUserAttributes, getCurrentUser, signIn, signOut} from "aws-amplify/auth";
import { generateClient } from 'aws-amplify/data';
import { defineNuxtPlugin } from '#app';
import outputs from '../amplify_cognito_setting.json';

const client = generateClient();

export default defineNuxtPlugin({
    name: 'AmplifyAPIs',
    enforce: 'pre',

    setup() {        
        Amplify.configure(outputs, { ssr: true });
        return {
            provide: {
                Amplify: {
                    Auth: {
                        fetchAuthSession,
                        fetchUserAttributes,
                        getCurrentUser,
                        signIn,
                        signOut
                    },
                    GraphQL: {
                        client
                    }
                }
            }
        }
    }
})

 

01.amplifyApis.server.ts

server 側で実行する plugin です。こちらは率直に言うと、公式 URL の実装例から client の実装をベースに明らかに不要な項目を除外した程度の変更しかしていないため、正直あまり言及できる内容がありません。幸いにも公式 URL の実装例に記載されていた機能/メソッドしか使用しなかったためこのような対応で OK でしたが、他に使用したいものがあった場合はモジュールの実装内容そのものを読み解く必要があったかもしれないです。

認証用の情報を cookie に入れているようで、有効期限が実装例だと30日間となっているため、要件次第でこの点は見直した方が良さそうです。そもそも cookie を使用するかどうかを含めて検討すべきかもしれませんが・・

また、aws-amplify 用の設定ファイルを読み込んでいるのは一緒ですが、Amplify.configure は client 側のメソッドであるため、同設定ファイルから読み込んだ内容をベースに aws-amplify/auth 及び aws-amplify/data ごとに必要な情報をパースしています。

import type { CookieRef } from 'nuxt/app';
import type {
    LibraryOptions,
    FetchAuthSessionOptions
} from '@aws-amplify/core';
import {
    createKeyValueStorageFromCookieStorageAdapter,
    createUserPoolsTokenProvider,
    createAWSCredentialsAndIdentityIdProvider,
    runWithAmplifyServerContext
} from 'aws-amplify/adapter-core';
import { parseAmplifyConfig } from 'aws-amplify/utils';
import {
    fetchAuthSession,
    fetchUserAttributes,
    getCurrentUser
} from 'aws-amplify/auth/server';
import { generateClient } from 'aws-amplify/api/server';
import outputs from '@/amplify_cognito_setting.json';

// parse the content of `amplify_outputs.json` into the shape of ResourceConfig
const amplifyConfig = parseAmplifyConfig(outputs);

// create the Amplify used token cookies names array
const userPoolClientId = amplifyConfig.Auth!.Cognito.userPoolClientId;
const lastAuthUserCookieName = `CognitoIdentityServiceProvider.${userPoolClientId}.LastAuthUser`;

// create a GraphQL client that can be used in a server context
const gqlServerClient = generateClient({ config: amplifyConfig });
const getAmplifyAuthKeys = (lastAuthUser: string) =>
    ['idToken', 'accessToken', 'refreshToken', 'clockDrift']
        .map(
            (key) =>
                `CognitoIdentityServiceProvider.${userPoolClientId}.${lastAuthUser}.${key}`
        )
        .concat(lastAuthUserCookieName);

// define the plugin
export default defineNuxtPlugin({
    name: 'AmplifyAPIs',
    enforce: 'pre',

    setup() {
        // The Nuxt composable `useCookie` is capable of sending cookies to the
        // client via the `SetCookie` header. If the `expires` option is left empty,
        // it sets a cookie as a session cookie. If you need to persist the cookie
        // on the client side after your end user closes your Web app, you need to
        // specify an `expires` value.
        //
        // We use 30 days here as an example (the default Cognito refreshToken
        // expiration time).
        const expires = new Date();
        expires.setDate(expires.getDate() + 30);

        // Get the last auth user cookie value
        //
        // We use `sameSite: 'lax'` in this example, which allows the cookie to be
        // sent to your Nuxt server when your end user gets redirected to your Web
        // app from a different domain. You should choose an appropriate value for
        // your own use cases.
        const lastAuthUserCookie = useCookie(lastAuthUserCookieName, {
            sameSite: 'lax',
            expires,
            secure: true
        });

        // Get all Amplify auth token cookie names
        const authKeys = lastAuthUserCookie.value
            ? getAmplifyAuthKeys(lastAuthUserCookie.value)
            : [];
        // Create a key-value map of cookie name => cookie ref
        //
        // Using the composable `useCookie` here in the plugin setup prevents
        // cross-request pollution.
        const amplifyCookies = authKeys
            .map((name) => ({
                name,
                cookieRef: useCookie(name, { sameSite: 'lax', expires, secure: true })
            }))
            .reduce<Record<string, CookieRef<string | null | undefined>>>(
                (result, current) => ({
                    ...result,
                    [current.name]: current.cookieRef
                }),
                {}
            );

        // Create a key value storage based on the cookies
        //
        // This key value storage is responsible for providing Amplify Auth tokens to
        // the APIs that you are calling.
        //
        // If you implement the `set` method, when Amplify needed to refresh the Auth
        // tokens on the server side, the new tokens would be sent back to the client
        // side via `SetCookie` header in the response. Otherwise the refresh tokens
        // would not be propagate to the client side, and Amplify would refresh
        // the tokens when needed on the client side.
        //
        // In addition, if you decide not to implement the `set` method, you don't
        // need to pass any `CookieOptions` to the `useCookie` composable.
        const keyValueStorage = createKeyValueStorageFromCookieStorageAdapter({
            get(name) {
                const cookieRef = amplifyCookies[name];
                if (cookieRef && cookieRef.value) {
                    return { name, value: cookieRef.value };
                }
                return undefined;
            },
            getAll() {
                return Object.entries(amplifyCookies).map(([name, cookieRef]) => {
                    return { name, value: cookieRef.value ?? undefined };
                });
            },
            set(name, value) {
                const cookieRef = amplifyCookies[name];
                if (cookieRef) {
                    cookieRef.value = value;
                }
            },
            delete(name) {
                const cookieRef = amplifyCookies[name];
                if (cookieRef) {
                    cookieRef.value = null;
                }
            }
        });

        // Create a token provider
        const tokenProvider = createUserPoolsTokenProvider(
            amplifyConfig.Auth!,
            keyValueStorage
        );

        // Create a credentials provider
        const credentialsProvider = createAWSCredentialsAndIdentityIdProvider(
            amplifyConfig.Auth!,
            keyValueStorage
        );

        // Create the libraryOptions object
        const libraryOptions: LibraryOptions = {
            Auth: {
                tokenProvider,
                credentialsProvider
            }
        };

        return {
            provide: {
                // You can add the Amplify APIs that you will use on the server side of
                // your Nuxt app here. You must only use the APIs exported from the
                // `aws-amplify/<category>/server` subpaths.
                //
                // You can call the API by via the composable `useNuxtApp()`. For example:
                // `useNuxtApp().$Amplify.Auth.fetchAuthSession()`
                //
                // Recall that Amplify server APIs are required to be called in a isolated
                // server context that is created by the `runWithAmplifyServerContext`
                // function.
                Amplify: {
                    Auth: {
                        fetchAuthSession: (options: FetchAuthSessionOptions) =>
                            runWithAmplifyServerContext(
                                amplifyConfig,
                                libraryOptions,
                                (contextSpec) => fetchAuthSession(contextSpec, options)
                            ),
                        fetchUserAttributes: () =>
                            runWithAmplifyServerContext(
                                amplifyConfig,
                                libraryOptions,
                                (contextSpec) => fetchUserAttributes(contextSpec)
                            ),
                        getCurrentUser: () =>
                            runWithAmplifyServerContext(
                                amplifyConfig,
                                libraryOptions,
                                (contextSpec) => getCurrentUser(contextSpec)
                            )
                    },
                    GraphQL: {
                        client: {
                            // Follow this typing to ensure the`graphql` API return type can
                            // be inferred correctly according to your queries and mutations
                            graphql: <
                                FALLBACK_TYPES = unknown,
                                TYPED_GQL_STRING extends string = string
                            >(
                                options: GraphQLOptionsV6<FALLBACK_TYPES, TYPED_GQL_STRING>,
                                additionalHeaders?: Record<string, string>
                            ) =>
                                runWithAmplifyServerContext<
                                    GraphQLResponseV6<FALLBACK_TYPES, TYPED_GQL_STRING>
                                >(amplifyConfig, libraryOptions, (contextSpec) =>
                                    gqlServerClient.graphql(
                                        contextSpec,
                                        options,
                                        additionalHeaders
                                    )
                                )
                        }
                    }
                }
            }
        };
    }
});

 

まとめ

コードの分量は多いですが、小ネタに相応しい内容でした。毎回気合いの入ったエントリを書くのがそろそろ難しくなってきたので、こういうちょっとした内容も増やしていければなと思います。(書きながらネタを増やしていけるように努力しているつもりですが・・)ともあれ2桁投稿することが当初の目標だったので、ひとまず達成できてよかったです。

本記事がどなたかの役に立てば幸いです。

タイトルとURLをコピーしました