😺

Next.jsと型安全session

2022/06/02に公開

Next.jsをBFFサーバーで使う時、セッションを使いたいケースもあるかと思います。この際にnext-session が結構便利で一工夫すれば型安全なセッション管理ができるので紹介です。

next-sessionのメリット

expressでRedisなどを利用してセッション管理する例はGoogleで調べれば結構出てきます。Next.jsでもexpressをカスタムサーバーとして利用すれば、expressのエコシステムが利用できるのでNext.jsでセッション管理をしたいならこれも1つの案です。一方でnext-sessionを利用する場合にはexpressを必要としないので、expressの実装や設定が当然不要だったり、依存関係を減らせるというメリットがあります。

next-sessionの導入

installはいつものやつです。

// NPM
npm install next-session
// Yarn
yarn add next-session

next-sessionでセッションを利用するには以下の実装が必要になります。

  • sessionのファクトリー関数(本稿におけるgetSession)の作成
  • プロダクション利用では必須オプションであるstore(SessionStore)の実装

前者は共通の設定などを渡しておくために必要な作業で、後者はRedisなどの外部Storeを想定しているため必要な作業です。

getSessionの実装

getSessionは公式通りだと以下のようになっています。

// ./lib/get-session.js
import nextSession from "next-session";
export const getSession = nextSession(options);

このgetSessionを利用して、API Routsやpagesで以下のように利用できます。

API Routes

import { getSession } from "./lib/get-session.js";

export default async function handler(req, res) {
  const session = await getSession(req, res);
  session.views = session.views ? session.views + 1 : 1;
  // Also available under req.session:
  // req.session.views = req.session.views ? req.session.views + 1 : 1;
  res.send(
    `In this session, you have visited this website ${session.views} time(s).`
  );
}

pages

import { getSession } from "./lib/get-session.js";

export default function Page({ views }) {
  return (
    <div>In this session, you have visited this website {views} time(s).</div>
  );
}

export async function getServerSideProps({ req, res }) {
  const session = await getSession(req, res);
  session.views = session.views ? session.views + 1 : 1;
  // Also available under req.session:
  // req.session.views = req.session.views ? req.session.views + 1 : 1;
  return {
    props: {
      views: session.views,
    },
  };
}

getSessionに型をつける

公式のサンプル実装のままでももちろん良いのですが、このままだと実際に利用する際のsessionにどんな値がアプリケーションから設定されてるか定義されておらず、[key: string]: anyになってしまいます。これに型をつけていきましょう。

まずnextSessionの型定義を確認してみましょう。

// lib/session.d.ts
export default function session(options?: Options): (req: IncomingMessage & {
  session?: Session;
}, res: ServerResponse) => Promise<Session>;
// lib/type.d.ts
export declare type SessionData = {
  [key: string]: any;
  cookie: Cookie;
};
export interface Session extends SessionData {
    id: string;
    touch(): void;
    commit(): Promise<void>;
    destroy(): Promise<void>;
    [isNew]?: boolean;
    [isTouched]?: boolean;
    [isDestroyed]?: boolean;
}

getSessionを実行するとPromise<Session>が得られるわけですが、SessionSessionDataは独自のメソッドや[key: string]: any;なので実際にアプリケーション側でどんな値を入れてるのかわかりません。そのため、セッションObjectに型を付けたいなら少々工夫が必要です。

// ./lib/get-session.ts
import nextSession from "next-session";

// ここにセッションの型を記述
export type AppSession = {
  accessDate?: Date;
};

// nextSession()の戻り値型を取得
type NextSessionInstance = ReturnType<typeof nextSession>;
// NextSessionInstanceの引数型を取得
type GetSessionArgs = Parameters<NextSessionInstance>;
// NextSessionInstanceの戻り値Promise<T>からTを取得し、cookieとidのみ取得
type GetSessionReturn = Pick<Awaited<ReturnType<NextSessionInstance>>, 'cookie' | 'id'>;

// getSessionの型を再定義
export const getSession: (
  ...args: GetSessionArgs
) => Promise<GetSessionReturn & AppSession> = nextSession();

nextSessionは高階関数の型のみ定義されていますが、ここではgetSessionの戻り値に型を付けたいのでnextSessionの型を分解してgetSessionの戻り値をPromise<GetSessionReturn & AppSession>に再定義しています。これでAppSessionにセッションとして保持したい型を定義すれば型安全にセッションを扱えるようになりました。getServerSidePropsなどでsessionを作成してsetしようとすると、IDEなどで補完されるはずです。

また、SessionStoreで直接利用する可能性のあるcookie/idのみ抽出しているので、これらも利用可能です。この抽出を行わないと[k: string]: anyが残ってしまうので注意しましょう。

これで実際セッションを利用しようとした時に、定義してない代入や参照はエラーとなります。

export const getServerSideProps: GetServerSideProps<Props> = async ({ req, res }) => {
  const session = await getSession(req, res)
  session.name = 'Taro' // 補完が効く
  session.hoge = false // error

  return {
    props: {
      name: session.name,
    }
  }
}

Session Storeの実装

次はSessionStoreの実装になります。例としてここではioredis を使って実装します。next-sessionのドキュメント例が少しわかりづらいですが、RedisStorepromisifyStoreに渡せばSessionStore型の戻り値を得られます。

yarn add ioredis connect-redis express-session
yarn add -D @types/connect-redis
// ./lib/get-session.ts
import nextSession from "next-session";
import { expressSession, promisifyStore } from "next-session/lib/compat";
import RedisStoreFactory from "connect-redis";
import Redis from "ioredis";

const RedisStore = RedisStoreFactory(expressSession);

// ここにセッションの型を記述
export type AppSession = {
  name?: string;
};

// nextSession()の戻り値型を取得
type NextSessionInstance = ReturnType<typeof nextSession>;
// NextSessionInstanceの引数型を取得
type GetSessionArgs = Parameters<NextSessionInstance>;
// NextSessionInstanceの戻り値Promise<T>からTを取得し、cookieとidのみ取得
type GetSessionReturn = Pick<Awaited<ReturnType<NextSessionInstance>>, 'cookie' | 'id'>;

// getSessionの型を再定義
export const getSession: (
  ...args: GetSessionArgs
) => Promise<GetSessionReturn & AppSession> = nextSession({
  store: promisifyStore(
    new RedisStore({
      client: new Redis(), // 必要に応じてhostやport
    })
  ),
});

テスト時のセッションデータの準備

jestでgetServerSidePropsに独自のreqなどを渡してテストしたいこともあるでしょう。next-sessionは内部的にはreq.sessionがあれば即時returnするので、これを利用するとテスト用のセッションデータの用意も簡単に行えます(若干ハックよりですが...)。

https://github.com/hoangvvo/next-session/blob/v4.0.4/src/session.ts#L56

以下getServerSidePropsのテストの参考記事です。

https://zenn.dev/takepepe/articles/testing-gssp-and-api-routes

この記事に出てくるgsspCtxなどでreqを作成する際に、以下のように修正すればテスト用のセッションデータが簡単に作成できます。

export const gsspCtx = (
  ctx?: Partial<GetServerSidePropsContext>,
  session?: AppSession,
): GetServerSidePropsContext => ({
  req: createRequest({
    session: session ?? {},
  }),
  res: createResponse(),
  params: undefined,
  query: {},
  resolvedUrl: "",
  ...ctx,
});

まとめ

next-session自体スター数もちょっと少ないし、ユーザー単位のkeyとCookieの紐付けだけ作るんだったら自前で実装しようかなと最初は思ってたんですが、実際next-session使うと非常に楽でした。

テストがちょっとハックよりなので、そこだけもうちょっと改善できないかが残課題ですね。

Discussion