🎈

Next.jsで複数のmiddlewareの関数を連結させる

2023/08/14に公開

はじめに

  • middleware.ts で複数の middleware の関数を連結させる方法を紹介します。

  • 下記が実装コードです。

https://github.com/hayato94087/nextjs-middleware-chain-sample

前提

middleware について触ったことがない場合は、middleware の紹介記事をまずご参照ください。

https://zenn.dev/hayato94087/articles/ec16174696a375

あるいは、英語ですが公式サイトをご参照ください。

https://nextjs.org/docs/app/building-your-application/routing/middleware

結論

  • middleware.ts を起点に、middleware の関数を定義したファイルを作成する。
  • Higher-order function を使って、middleware の関数を定義する。

参考とした記事/動画

以下の動画、GitHub のリポジトリを参考にしました。

実装して確認

実装して確認して行きます。

Next.jsプロジェクトの新規作成し環境構築

作業するプロジェクトを新規に作成していきます。

長いので、折り畳んでおきます。

新規プロジェクト作成と初期環境構築の手順詳細
$ pnpm create next-app@latest nextjs-middleware-chain-sample --typescript --eslint --import-alias "@/*" --src-dir --use-pnpm --tailwind --app
$ cd nextjs-middleware-chain-sample

以下の通り不要な設定を削除し、プロジェクトの初期環境を構築します。

$ mkdir src/styles
$ mv src/app/globals.css src/styles/globals.css
src/styles/globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;
src/app/page.tsx
export default function Home() {
  return (
    <main className="text-lg">
      テストページ
    </main>
  )
}
src/app/layout.tsx
import '@/styles/globals.css'

export const metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ja">
      <body className="">{children}</body>
    </html>
  );
}
tailwind.config.js
import type { Config } from "tailwindcss";

const config: Config = {
  content: [
    "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
    "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
  ],
  plugins: [],
};
export default config;
tsconfig.json
{
  "compilerOptions": {
    "target": "es5",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "plugins": [
      {
        "name": "next"
      }
    ],
+   "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"],
    }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
  "exclude": ["node_modules"]
}

コミットします。

$ pnpm build
$ git add .
$ git commit -m "新規にプロジェクトを作成し, 作業環境を構築"

ステップ1;Miiddleware で機能を実装

Middleware で2つの要素を実装します。

要素 説明
IP制限 全てのアクセスに対して特定のIP制限実施します。
ログ出力 アクセスに対してユーザーのリクエスト情報を出力する

環境変数をファイルを作成します。アクセスを許可する IP アドレスを設定します。

$ touch .env.local
.env.local
IP_WHITE_LIST=xxx.xxx.xxx.xxx, yyy.yyy.yyy.yyy

Middleware を実装します。

$ touch src/middleware.ts
src/middleware.ts
import { NextRequest, NextResponse } from "next/server";

export function middleware(request: NextRequest) {
  // ##################################################
  // ログ出力
  //
  // パスが以下の場合にログを出力します。
  // ・"/"から始まり、"."を含まない任意のパス
  // ・"/_nextから始まらない任意のパス
  // ・"/"のルートパス。
  // ・"/api"から始まる任意のパス
  // ・"/trpc"から始まる任意のパス
  // ##################################################
  if (
    request.nextUrl.pathname.match(/\/(?!.*\..*|_next).*/) ||
    request.nextUrl.pathname.match(/\/(api|trpc)(.*)/) ||
    request.nextUrl.pathname === "/"
  ) {
    // リクエストの情報をJSON形式で出力します。
    const log = {
      ip: request.ip,
      geo: request.geo,
      url: request.nextUrl.pathname,
    };
    console.log(JSON.stringify(log, (k, v) => (v === undefined ? null : v)));
  }

  // ##################################################
  // IP制限
  //
  // 全てのパスに対してIP制限を実行します。
  // ##################################################
  // ホワイトリストに登録されたIPリストを取得します。
  const ipWhiteList = new Set(
    process.env.IP_WHITE_LIST?.split(",").map((item: string) => {
      return item.trim();
    })
  );
  // ホワイトリストに登録されていないIPアドレスからのアクセスは拒否します。
  if (request.ip && !ipWhiteList.has(request.ip as string)) {
    const log = {
      message: `許可されていないIPアドレスからのアクセスのためアクセスを拒否しました`,
      ip: request.ip,
    };
    console.log(log);
    return new NextResponse(null, { status: 401 });
  }

  return NextResponse.next();
}

コミットします。

$ pnpm build
$ git add .
$ git commit -m "Miiddleware で機能を実装"

GitHub にリポジトリを作成し、Vercel へデプロイします。Vercel へデプロイする際に、環境変数に IP_WHITE_LIST を定義し IP アドレスを設定します。

Vercel 上で動作確認します。なお、ローカル環境で動作確認する場合は、IP アドレスなど取得できない変数があるため、必ず Vercel 上で確認してください。

まず、ホワイトリストに登録されていない IP からアクセスします。すると、401 が返却されます。想定どおりです。

Vercel の管理画面のサーバーログを確認すると、IP 制限がかかっていることが分かります。

Time Status Code Host HTTPメソッド ログ
AUG 14 07:29:22.33 - nextjs-middleware-chain-sample.vercel.app [GET] / { message: '許可されていないIPアドレスからのアクセスのためアクセスを拒否しました', ip: 'xxx.xxx.xxx.xxx' }
AUG 14 07:29:22.33 - nextjs-middleware-chain-sample.vercel.app [GET] / {"ip":"xxx.xxx.xxx.xxx","geo":{"city":"Honcho","country":"JP","latitude":"xx.xxxx","longitude":"xx.xxxx","region":"13"},"url":"/"}
AUG 14 07:29:22.00 401 nextjs-middleware-chain-sample.vercel.app [GET] /
AUG 14 07:29:21.99 401 nextjs-middleware-chain-sample.vercel.app [GET] / [GET] [middleware: "src/middleware"] / status=401
AUG 14 07:29:17.27 - nextjs-middleware-chain-sample.vercel.app [GET] /favicon.ico { message: '許可されていないIPアドレスからのアクセスのためアクセスを拒否しました', ip: 'xxx.xxx.xxx.xxx' }
AUG 14 07:29:17.15 401 nextjs-middleware-chain-sample.vercel.app [GET] /favicon.ico [GET] [middleware: "src/middleware"] /favicon.ico status=401
AUG 14 07:29:17.00 401 nextjs-middleware-chain-sample.vercel.app [GET] /favicon.ico

続いて、ホワイトリストに登録されている IP からアクセスします。正しく動作しています。

Vercel の管理画面のサーバーログからも問題なくアクセスできていることが分かります。

Time Status Code Host HTTPメソッド ログ
AUG 14 07:33:40.00 304 nextjs-middleware-chain-sample.vercel.app [GET] /favicon.ico
AUG 14 07:33:39.67 200 nextjs-middleware-chain-sample.vercel.app [GET] /favicon.ico [GET] [middleware: "src/middleware"] /favicon.ico status=200
AUG 14 07:33:39.00 200 nextjs-middleware-chain-sample.vercel.app [GET] /
AUG 14 07:33:38.37 - nextjs-middleware-chain-sample.vercel.app [GET] / {"ip":"xxx.xxx.xxx.xxx","geo":{"city":"Tokyo","country":"JP","latitude":"xxx.xxx","longitude":"xxx.xxx","region":"13"},"url":"/"}
AUG 14 07:33:37.67 200 nextjs-middleware-chain-sample.vercel.app [GET] / [GET] [middleware: "src/middleware"] / status=200

ステップ2:Higher-Order Function(高階関数)でファイルを分割

Higher-order function(高階関数)を利用しファイルを分割します。Higher-order function(高階関数)とは、関数を引数として受け取り、関数を返す、関数です。言葉だとわかりにくいので、実装して行きます。

src/middleware.ts
import {
  NextFetchEvent,
  NextMiddleware,
  NextRequest,
  NextResponse,
} from "next/server";

function widthLogging(middleware: NextMiddleware) {
  return async (request: NextRequest, event: NextFetchEvent) => {
    // ##################################################
    // ログ出力
    //
    // パスが以下の場合にログを出力します。
    // ・"/"から始まり、"."を含まない任意のパス
    // ・"/_nextから始まらない任意のパス
    // ・"/"のルートパス。
    // ・"/api"から始まる任意のパス
    // ・"/trpc"から始まる任意のパス
    // ##################################################
    if (
      request.nextUrl.pathname.match(/\/(?!.*\..*|_next).*/) ||
      request.nextUrl.pathname.match(/\/(api|trpc)(.*)/) ||
      request.nextUrl.pathname === "/"
    ) {
      // リクエストの情報をJSON形式で出力します。
      const log = {
        ip: request.ip,
        geo: request.geo,
        url: request.nextUrl.pathname,
        method: request.method,
      };
      console.log(JSON.stringify(log, (k, v) => (v === undefined ? null : v)));
    }

    return middleware(request, event);
  };
}

function widthIpRestriction(middleware: NextMiddleware) {
  return async (request: NextRequest, event: NextFetchEvent) => {
    // ##################################################
    // IP制限
    //
    // 全てのパスに対してIP制限を実行します。
    // ##################################################
    // ホワイトリストに登録されたIPリストを取得します。
    const ipWhiteList = new Set(
      process.env.IP_WHITE_LIST?.split(",").map((item: string) => {
        return item.trim();
      })
    );
    // ホワイトリストに登録されていないIPアドレスからのアクセスは拒否します。
    if (request.ip && !ipWhiteList.has(request.ip as string)) {
      const log = {
        message: `許可されていないIPアドレスからのアクセスのためアクセスを拒否しました`,
        ip: request.ip,
        url: request.nextUrl.pathname,
        method: request.method,
      };
      console.log(log);
      return new NextResponse(null, { status: 401 });
    }

    return middleware(request, event);
  };
}

export default widthLogging(widthIpRestriction(() => NextResponse.next()));

コミットします。

$ pnpm build
$ git add .
$ git commit -m "Higher-Order Function(高階関数)を定義し、ファイルを分割"
$ git push origin main

Vercel 上で正しく動作します。

ステップ3:ファイルを分割

1 つのファイルに全てを記述しても良いですが、メンテナンス性も考慮してファイルを分割します。

$ mkdir src/middlewares
$ touch src/middlewares/widthIpRestriction.ts
$ touch src/middlewares/withLogging.ts
src/middlewares/widthIpRestriction.ts
import {
  NextFetchEvent,
  NextMiddleware,
  NextRequest,
  NextResponse,
} from "next/server";

export function widthIpRestriction(middleware: NextMiddleware) {
  return async (request: NextRequest, event: NextFetchEvent) => {
    // ##################################################
    // IP制限
    //
    // 全てのパスに対してIP制限を実行します。
    // ##################################################
    // ホワイトリストに登録されたIPリストを取得します。
    const ipWhiteList = new Set(
      process.env.IP_WHITE_LIST?.split(",").map((item: string) => {
        return item.trim();
      })
    );
    // ホワイトリストに登録されていないIPアドレスからのアクセスは拒否します。
    if (request.ip && !ipWhiteList.has(request.ip as string)) {
      const log = {
        message: `許可されていないIPアドレスからのアクセスのためアクセスを拒否しました`,
        ip: request.ip,
        url: request.nextUrl.pathname,
        method: request.method,
      };
      console.log(log);
      return new NextResponse(null, { status: 401 });
    }

    return middleware(request, event);
  };
}
src/middlewares/withLogging.ts
import {
  NextFetchEvent,
  NextMiddleware,
  NextRequest,
} from "next/server";

export function widthLogging(middleware: NextMiddleware) {
  return async (request: NextRequest, event: NextFetchEvent) => {
    // ##################################################
    // ログ出力
    //
    // パスが以下の場合にログを出力します。
    // ・"/"から始まり、"."を含まない任意のパス
    // ・"/_nextから始まらない任意のパス
    // ・"/"のルートパス。
    // ・"/api"から始まる任意のパス
    // ・"/trpc"から始まる任意のパス
    // ##################################################
    if (
      request.nextUrl.pathname.match(/\/(?!.*\..*|_next).*/) ||
      request.nextUrl.pathname.match(/\/(api|trpc)(.*)/) ||
      request.nextUrl.pathname === "/"
    ) {
      // リクエストの情報をJSON形式で出力します。
      const log = {
        ip: request.ip,
        geo: request.geo,
        url: request.nextUrl.pathname,
        method: request.method,
      };
      console.log(JSON.stringify(log, (k, v) => (v === undefined ? null : v)));
    }

    return middleware(request, event);
  };
}
src/middlewares.ts
import { NextResponse } from "next/server";
import { widthLogging } from "@/middlewares/withLogging";
import { widthIpRestriction } from "@//middlewares/widthIpRestriction";

export default widthLogging(widthIpRestriction(() => NextResponse.next()));

コミットします。

$ pnpm build
$ git add .
$ git commit -m "ファイルを分割"
$ git push origin main

Vercel 上で正しく動作します。

ステップ4:関数をチェイン

以下のような記述も良いですが、より簡潔に記載するために関数をチェインする方法を実装します。

src/middlewares.ts
...
export default widthLogging(widthIpRestriction(() => NextResponse.next()));

以下がチェインした場合の実装です。配列の順序には意味があり、配列の先頭から実行されます。

src/middlewares.ts
...
export default chainMiddlewares([widthLogging, widthIpRestriction]);

では作成します。

$ mkdir -p src/types
$ touch    src/types/middleware.d.ts \
           src/middlewares/chain.ts
src/types/middleware.d.ts
import { NextMiddleware } from "next/server";

export type MiddlewareFactory = (middleware: NextMiddleware) => NextMiddleware;
src/middlewares/chain.ts
import { NextMiddleware, NextResponse } from "next/server";
import { MiddlewareFactory } from "@/types/middleware";

export function chainMiddlewares(
  functions: MiddlewareFactory[] = [],
  index = 0
): NextMiddleware {
  const current = functions[index];
  if (current) {
    const next = chainMiddlewares(functions, index + 1);
    return current(next);
  }
  return () => NextResponse.next();
}
src/middlewares.ts
import { widthLogging } from "@/middlewares/withLogging";
import { widthIpRestriction } from "@//middlewares/widthIpRestriction";
import { chainMiddlewares } from "@/middlewares/chain";

// 関数は配列の順番に実行されます。
// ・全てのアクセスに対してログを出力します。
// ・IP制限を実行します。
export default chainMiddlewares([widthLogging, widthIpRestriction]);

コミットします。

$ pnpm build
$ git add .
$ git commit -m "より簡潔に記載するために関数をチェインする方法を実装"
$ git push origin main

Vercel 上で正しく動作します。

まとめ

  • middleware.ts で複数の middleware の関数を連結させる方法を紹介しました。

  • 下記が実装コードです。

https://github.com/hayato94087/nextjs-middleware-chain-sample

参考

Discussion