SCSKの畑です。10回目の投稿です。
今回は6回目のエントリで記載した、aws-amplify のSSR (Nuxt3)対応の詳細もとい補足について記載します。小ネタです。
対応の背景などは同エントリに記載しているのでここでは省略します。
aws-amplify 用のコンフィグファイル実装
aws-amplify モジュールを使用するにあたり、AppSync や Cognito など、モジュールが使用する各種サービスの情報が必要となります。言い換えると、Amplify.configure の引数としてどのような内容を設定するべきか、です。
ところが、具体的にどのように設定すべきなのかというドキュメントや情報が相対的に乏しく、あったとしても aws-amplify のバージョンが異なっておりコピペだと動かないなど、ちゃんと動かすまで結構苦労したため備忘として残しておこうと思います。例えば、GraphQL のデフォルト認証モードが IAM な場合は値を 「iam」に設定するみたいな情報とか、少なくとも実装時点ではちょっと調べた程度だと出てこなかったりしたんですよね。公式ドキュメントでは以下 URL が対応すると思うのですが、一覧としての記載はありませんし。。

ということで、今回の場合の実装例を示してみます。対象は 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" } } }
なお、他サービスを含めたより詳細な内容については、以下広野さんのエントリも合わせてご参照ください。私も最初にこのエントリを見ていさえすれば、試行錯誤に数時間を費やすところまではいかなかったでしょう・・

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

なお、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桁投稿することが当初の目標だったので、ひとまず達成できてよかったです。
本記事がどなたかの役に立てば幸いです。