Azure Static Web App ServiceにNext.js(App Router)をデプロイする。

以前にAzure Web App Serviceへのデプロイについての記事を書きました。 ma38su.hatenablog.com

今回はこのデプロイをブラッシュアップして、Azure Pipelineからstatic web appへもデプロイできるようにしました。

2024/2/10 追記

Next.JSのmiddlewareうまく動作しない問題もります。原理はよくわかってないもののNextAuth.jsは期待通りの動作をしました。 それに加えてたデプロイがうまくいかないトラブルも起きたため、自分はWebAppServiceに戻しました。

Next.jsのビルドの設定

まず前提として、next.jsをstandaloneモードでビルドします。ビルド先のディレクトリも指定しておきます。 ビルド先を指定しない場合はこの先の処理が変わると思います。

/** @type {import('next').NextConfig} */
const nextConfig = {
  output: 'standalone',
  distDir: 'build',
}
module.exports = nextConfig

次に、デプロイ関係のちょっとした処理を書き足します。Next.jsではstandaloneモードでビルドすると、publicディレクトリなどの配置を調整してやる必要があります。(Vercelを使う場合は気にしなくていいわけですが。) Azure PipelineからAzure Static Web Appへのビルドでは、CI/CDパイプライン中でビルドを実行しないので、package.jsonに書くことにします。 次に、package.jsonのscriptsに、の記述の例を示します。postbuildにnpm run buildの完了後に実行する処理を記述します。

{
  ...
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "postbuild": "cp -r public build/standalone && mkdir -p build/standalone/public/_next && cp -r build/static build/standalone/public/_next/",
    "start": "node build/standalone/server.js",
    "nstart": "next start",
    "lint": "next lint"
  },
  ...
}

以下は必要ないのかもしれない。(Azure static appへのデプロイでよしなに対応されているから?)

cp -r build/static build/standalone/public/_next/

ビルドのための設定は以上です。

Azure Pipelineの定義

以下でOK。

name: Azure Static Web Apps CI/CD

trigger:
  - main

jobs:
- job: build_and_deploy_job
  displayName: Build and Deploy Job
  pool:
    vmImage: ubuntu-latest
  steps:
  - checkout: self
  - task: AzureStaticWebApp@0
    inputs:
      production_branch: 'main'
      azure_static_web_apps_api_token: $(AZURE_STATIC_WEB_APPS_API_TOKEN)
      app_location: "/"
      output_location: 'build'

これだけです。 プリセットのタスクAzureStaticWebApp@0がよしなにやってくれます。 $(AZURE_STATIC_WEB_APPS_API_TOKEN)でお気づきかと思いますが、VariablesにAzure Static Web App Serviceで発行したTokenを設定する必要はあります。

NextAuth.jsでAutodeskアカウントでセッション管理するカスタムプロバイダーを作ってみた。

NextJsの認証を簡単に実現するモジュールのひとつであるNextAuth.jsについて、Autodeskアカウントを使ってみようということで、 今回はカスタムプロバイダーとしてAutodesk Platform ServicesのOAuthを試してみたので、その結果を残しておきます。

いまauth.jsへのリブランディング中なのと、nextjs自体でのPage RouterからApp Routerへの移行が重なっているからか、だいぶドキュメントはグダグダな状態でしたが、 既存のプロバイダーの定義をみながらやってみたらあっさりカスタムプロバイダーも動作しました。 (細部まで設定を詰めるといろいろあるのかもしれませんが。)

公式のAPIリファレンスはこちら。 APIs | Autodesk Platform Services

カスタムプロバイダーの作成

まず、カスタムプロバイダーの定義から。 以下を適当な場所、例えば/auth/providers/autodesk.tsというファイルを作ってPrividerを定義します。

import { OAuthConfig } from "next-auth/providers/oauth"

type AutodeskProfile = {
  sub: string,
  name: string,
  given_name: string,
  family_name: string,
  preferred_username: string,
  email: string,
  email_verified: boolean,
  profile: string,
  picture: string,
  locale: "en-US" | string,
  updated_at: number,
}

export default function Autodesk(
  {
    clientId,
    clientSecret,
    logo,
  }: {
    clientId: string,
    clientSecret: string,
    logo: string,
  }
): OAuthConfig<AutodeskProfile> {

  return {
    id: "autodesk",
    name: "Autodesk",
    type: "oauth",
    version: '2.0',
    checks: [],
    authorization: {
      url: "https://developer.api.autodesk.com/authentication/v2/authorize",
      params: {
        response_type: 'code',
        scope: "data:read",
      },
    },
    token: 'https://developer.api.autodesk.com/authentication/v2/token',
    userinfo: "https://api.userprofile.autodesk.com/userinfo",
    profile(profile) {
      const { sub: id, name, email, picture: image } = profile;
      return { id, name, email, image }
    },
    style: {
      logo,
      bg: "#000",
      text: "#fff",
    },
    options: {
      clientId,
      clientSecret,
    },
  }
}

AutodeskProfileは、APSのuser infoのAPIから取得できるユーザ情報に則って定義しています。 これをNextAuth.jsで求められるUser型に変換して使うわけです。 logoにはロゴの画像を準備してその画像のURLを渡します。 versionは指定しなくても動作しました。

カスタムプロバイダーの設定(App Router)

次に、作成したProviderを設定していきます。 app/api/auth/[...nextauth]/route.tsを作成し、以下とすればOKです。 あとは環境変数としてAPS_CLIENT_ID、APS_CLIENT_SECRETなどをよしなに定義する必要はあります。 (開発環境であれば.env.localなどに定義しておきます。)

import NextAuth from "next-auth";
import AutodeskProvider from "@/auth/providers/autodesk";

const handler = NextAuth({
  providers: [
    AutodeskProvider({
      clientId: process.env.APS_CLIENT_ID!,
      clientSecret: process.env.APS_CLIENT_SECRET!,
    })
  ],
});

export {
  handler as GET, handler as POST,
};

認証の適用

とりあえず丸ごと認証をかけてみます。

export { default } from "next-auth/middleware"

ページでもAPIでも認証されてなければ、/api/auth/signinにリダイレクトされるようになります。

ただ、また認証前の画面で表示するロゴは認証されてなくても表示する必要があります。 APIもリダイレクトではなく、401などのコードを返すべきではあります。

認証の対象外にするページやリソースAPIがある場合は以下のようにconfigのmatcherで定義できます。

export const config = {
  matcher: ["/((?!adsk-logo.svg|api).*)"], // ?!で否定です。
};

next-auth.js.org

API

認証の適用からAPIは除外した場合には、API個別に認証状態を判定させる必要があります。

サーバサイドでは、getServerSessionが使えます。

import { getServerSession } from "next-auth";
import { AuthOptions } from "./auth/[...nextauth]/route";

export async function GET() {
  const session = await getServerSession(AuthOptions);
  if (!session) {
    return new Response('401 Unauthorized', { status: 401 });
  }
  return Response.json({user: session.user});
}

ページ

フロントエンドの各ページでセッション情報を使うにはuseSessionというhookが用意されています。

'use client'
import React from "react";
import { signIn, signOut, useSession } from "next-auth/react";

function AuthPage() {
  const { data, status } = useSession();

  switch (status) {
    case 'loading':
      return <div>Loading....</div>;
    case 'authenticated':
      if (data != null) {
        const { name, email, image } = data.user!;
        return (<>
          <h2>Authorized</h2>
          <ul>
            <li>Name: {name}</li>
            <li>Email: {email}</li>
            <li>{ image && <img src={image} alt='profile' width={40} height={40} />}</li>
          </ul>
  
          <button onClick={() => signOut()}>
            Sign Out
          </button>
        </>)
      }
    default:
      return <div>
        <h2>Unauthorized</h2>

        <button onClick={() => signIn()}>
          Sign In
        </button>
      </div>
  }
}
export { AuthPage }

useSessionはクライアントでのみ動作するため、'use client'が必要です。 middlewareで認証状態をチェックするページだとすると、statusがunauthenticatedになることはないはずなのでそのあたりのコードは不要ではあります。

middlewareで認証をチェックしないとしても、useSessionのhookはページ自体のロードよりも数秒遅れるため、セッションデータはundefinedになることがあります。 この時間差が気になるので、これはRSCというかSSRというかサーバ側で解消することにします。

RSCに書き換え

サーバサイドで処理するため、'use client'を消して、useSessionをgetServerSessionに変更し、関数にasyncをつけます。 それから、Sign InボタンやSign Outボタンの関数は、クライアントコンポーネントでしか動作しないため、コンポーネントを切り出してそれぞれ'use client'を付けます。

import React, { ReactNode } from "react";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/route";
import { SignInButton } from "./SignInButton";
import { SignOutButton } from "./SignOutButton";

async function AuthPage() {
  const session = await getServerSession(AuthOptions);

  if (!session) {
    return <div>
      <h2>Unauthenticated</h2>
      <SignInButton />
    </div>
  }

  const { name, email, image } = session.user!;
  return (<>
    <h2>Authenticated</h2>
    <ul>
      <li>Name: {name}</li>
      <li>Email: {email}</li>
      <li><img src={image} alt='profile' width={40} height={40} /></li>
    </ul>

    <SignOutButton />
  </>)
}

export { AuthPage }

SignOut ボタンだけ例を示しておくと、こんな感じです。

'use client'

import { signOut } from "next-auth/react"

function SignOutButton() {
  return <button onClick={() => signOut()}>
    Sign Out
  </button>
}

export { SignOutButton }

これでページのロードと同時にセッション情報が表示されるようになりました。 コードもすっきりしたかと。

本番環境へのデプロイ

nextjsデフォルトのlocalhost:3000で開発しているうちは気づかないが、クラウドへのデプロイするとうまく動作しなくなって困りました。 ドキュメントを読むと、デプロイするサーバ上のURLを定義する必要があるとのこと。 (試行錯誤して一晩溶かしました。)

NEXTAUTH_URL

Provider選択画面のスキップ方法

Autodeskアカウントを対象とするようなケースでは、一般向けのアプリというよりは特定企業向けの用途となることが多いとおもいます、 そのような場合はProviderも複数用意しないので、ユーザにProviderを指定させる画面もスキップしたいことがあるかと思います。

調べてみると、組み込みのSignIn関数にProvider名を渡せばよいことがわかりました。

<button onClick={() => signIn('autodesk')}>Sign In</button>

とりあえずこんなところで。気が向いたら補足します。

GraphQLのサーバサイド認証とその実装のためのContext

新しい技術を試すにあたって、面倒ではあるが、認証までは一通り使いこなせる必要があると感じている。ただ、認証は結構めんどくさいし、すぐ忘れてしまいがちなので、メモを残しておく。

GraphQLサーバに、認証機能を付与するにあたって、Contextを定義する。このMyContextはGraphGLの各呼び出しに対して、引数のほかに共通的に呼び出される。ここでは認証を想定し、認証されていればMyContextはユーザ情報を含み、認証されていなければnullであるとする。

interface MyContext {
  user: User | null;
}

Resolverにも先ほどMyContextを設定する。/meのエンドポイントの定義の3つめの引数がMyContextとなるので、user情報を含んでいるかどうかをチェックしてエラーを投げている。エラーメッセージを適当に投げるとInternal Server Errorと扱われてしまうので、正確に認証エラーを返すには、GraphQLErrorを使ってカスタムエラーを投げることがある。

const resolvers: Resolvers<MyContext> = {
  Query: {
    me: (_parent, _args, {user}) => {
      if (user == null) throw new GraphQLError('Unauthorized', {
        extensions: { code: 'UNAUTHORIZED' }
      });
      return user
    },

もう2か所。ApolloServer生成時にもMyContextを設定しておき、startStandaloneServerのoptionsでは、設定したMyContext生成について定義する必要がある。今回はヘッダーで受け取ったAuthorizationの値からUser型のデータまたはnullを受け取り返すといった処理になっている。各ユーザ情報とAuthorizationの値とをマッチさせる返答することになる。

const server = new ApolloServer<MyContext>({
  typeDefs,
  resolvers,
});
const { url } = await startStandaloneServer(server, {
  listen: { port: 4000 },
  context: async ({req}) => {
    const token = req.headers.authorization || '';
    const user = getUser(token);
    return { user } satisfies MyContext;
  },
});

GraphQLの型定義をスキーマから自動生成する。

GraphQL、型定義をスキーマから自動生成するところまで試してみた。

やってみて気づいたこととして、自動生成する型定義はサーバとクライアントの2種類があるということ。それぞれ必要。

サーバ

サーバの方はschema.graphqlというようなスキーマ定義ファイルがあれば生成できる。

npm install -D @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-resolvers

公式はyamlでConfigを作成していたが、tsでもConfigは作成できる。

import type { CodegenConfig } from '@graphql-codegen/cli';

const config: CodegenConfig = {
  schema: "./src/schema.graphql",
  generates: {
    "./src/__generated__/resolvers-types.ts": {
      plugins: [
        "typescript",
        "typescript-resolvers",
      ],
      config: {
        useIndexSignature: true,
        contextType: "../index#MyContext",
      },
    },
  },
  overwrite: true,
};
export default config;

このファイルをcodegen-server.tsと保存してCLIでgenerateする。

npx graphql-codegen --config codegen-server.ts

これで./src/__generated__/resolvers-types.tsに型定義が出力されるので、importして、Resolverの型を置き換えてやればよい。

最小限のコードだけ示すと以下。

import { Resolvers } from './__generated__/resolvers-types';

const resolvers: Resolvers = {
  Query: {
    ...
  }
};

もちろん、スキーマ中で定義した型もimportできる。

Apollo-serverのドキュメントを参考にした。 Generating types from a GraphQL schema - Apollo GraphQL Docs

クライアント

GraphQLクライアントでは、クエリ毎に取得できるデータは異なる。そのため、クエリ毎に型定義が必要になる。

例えば以下のコードでは、gqlで宣言しているクエリが対象となる。 クエリ名(ここでのGetBooks)は自動生成されるGraphQLの型定義でも利用されるため、クエリ毎にユニークな名前を付ける必要がある。

import { ApolloClient, InMemoryCache } from '@apollo/client/core';
import { gql } from '@apollo/client/core';

const query = gql(`
query GetBooks {
  books {
    id
    title
  }
}
`);

async function query() {
  const client = new ApolloClient({
    uri: 'http://localhost:4000/',
    cache: new InMemoryCache(),
  });

  const { data: { books } } = await client.query({ query });
  books.map(book => {
    console.log({ book });
  })
}

void query();

クライアント用のConfigの定義は以下codegen-client.tsとする。 サーバのときのConfig異なるのは対象スキーマ以外にdocumentsでtsファイルを指定している。(クエリの記述があるコードを指定する必要がある) presetでのclientを指定も必要。

import type { CodegenConfig } from '@graphql-codegen/cli';

const config: CodegenConfig = {
  schema: "./src/schema.graphql",
  documents: ['src/**/*.{ts,tsx}'],
  generates: {
    "./src/__generated__/": {
      preset: 'client',
      plugins: [],
      presetConfig: {
        gqlTagName: 'gql',
      },
    },
  },
  ignoreNoDocuments: true,
  overwrite: true,
};

export default config;

ここでは無難にApolloClientを使っておく。

自動生成対象となるクエリをgqlでDocumentNodeに変換してからclientのqueryに渡すようにしておく。そうするとcodegenを実行した際に必要な型定義が作成の対象となる様子。

型定義の自動生成はサーバと変わらない。以下で実行する。

npx graphql-codegen --config codegen-client.ts

自動生成後は、 @apollo/client/coreからimportしていたgqlを自動生成したgqlに置き換えれば、生成された型が効くようになる。

import { gql } from '@apollo/client/core';
import { gql } from '../src/__generated__/gql';

クライアントのドキュメントはちょっとみつけられなかったので。React用のドキュメントを参考にしつつやってみたという形。 Generating types from a GraphQL schema - Apollo GraphQL Docs

GraphQLのクライアントを試してみる。

GraphQLの理解のため、まずはライブラリなど使わずfetchでGraphQLのクエリを発行してみる。

const query = gql`{ books { id } }`;

async function queryByFetch() {
  const res = await fetch('http://localhost:4000/graphql', {
    method: 'POST',
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      query,
    })
  });
  if (!res.ok) {
    throw new Error();
  }

  const result = await res.json() as any;
  if (!('data' in result)) {
    throw new Error();
  }
  const { data } = result;
  console.log({ data });
}

void queryByFetch();

できたので、ApolloClientを使ってみる。node上で使うなら、@apollo/client/coreからインポートが必要なよう。

import { ApolloClient, InMemoryCache, gql } from '@apollo/client/core';

const query = gql`{ books { id } }`;

async function queryByClient() {
  const client = new ApolloClient({
    uri: 'http://localhost:4000/',
    cache: new InMemoryCache(),
  });

  const result = await client.query({ query });
  if (!('data' in result)) {
    throw new Error();
  }
  const { data } = result;
  console.log({ data });
}

void queryByClient();

viteのサーバ用にリバースプロキシのミドルウェアを書いた。

expressを使うと楽に実装できた。

まだ試行錯誤しているので、とりあえずvite.config.tsにべた書き。

import { ViteDevServer, PluginOption, defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'

import express from 'express';
import type { IncomingMessage, ServerResponse } from 'http';
import { request } from 'http';

function createReverseProxyHandler(path: string, proxyHost: string, proxyPort: number) {
  const handler = function(proxyReq: IncomingMessage, proxyRes: ServerResponse) {
    const serverReq = request({
      host: proxyHost,
      port: proxyPort,
      method: proxyReq.method,
      path: proxyReq.url,
      headers: proxyReq.headers, 
    }).on('error', () => {
      proxyRes.writeHead(502).end();
    }).on('timeout', () => {
      proxyRes.writeHead(504).end();
    }).on('response', serverRes => {
      proxyRes.writeHead(serverRes.statusCode!, serverRes.headers)
      serverRes.pipe(proxyRes);
    })
    proxyReq.pipe(serverReq);
  }

  const router = express();
  router.use(path, handler);
  return router;
}

function ReverseProxyPlugin(props: {path: string, proxyHost: string, proxyPort: number}): PluginOption {
  const { path, proxyHost, proxyPort } = props;
  return {
    name: 'ReverseProxyPlugin',
    configureServer(server: ViteDevServer) {
      server.middlewares.use(createReverseProxyHandler(path, proxyHost, proxyPort));
    },
  } as const satisfies PluginOption;
}

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    react(),
    ReverseProxyPlugin({
      path: '/api/',
      proxyHost: '127.0.0.1',
      proxyPort: 4000,
    }),
  ]
})

SuspenseをNextJSで使う。

追記:NextJS(AppRouter)でSuspenseを使うときはCSRにしようと書いたのだが、CSRだとNextJSのRouter Cacheと相性がわるそう。というか、最近NextJSはオーバースペックだなぁと感じている。


Viteで作っていたReactのプロトをNextjsに移植したところ、Suspense周りでトラブルが起きた。ReactQueryもSWRでもどちらでも同じ症状が起きた。

エラーメッセージを見る限り、原因はあきらかにSSRだと予測できたため、SSRを無効にして、CSRにすれば解決するだろうと思われた。ただ、思ったより苦戦した。

調べるとまず見つかるのは以下のおまじない。これをコードの1行目に記載する。

'use client'

これでエラーは起きるが一応表示はされるようにはなった。 完全にCSRになるのかと思ったが、そうではなく、SSRされることもある様子。

次にSSRを無効にする方法について調べると、以下のおまじないが見つかった。

export default dynamic(async () => MyApp, {ssr: false} );

これを適用したら、Suspenseがエラーなく期待通り動作するようになった。

追記1

SSRでは、fallbackDataを設定すればSuspenseのコード自体は動作したのだが、ローディング中にはfallbackDataが返ってくるようになってしまい、期待した動作にならなかった。SSRの制約として受け入れるしかないのかもしれないが、よくわからない。

追記2

fallbackDataを設定すれば、と書いたのだが、これは勘違いでご作法(loading.tsxなど)に沿って書けば、AppRouterのSSRでもSuspenseはfallbackDataの設定なしでも動作した。