Nuxt + universal-cookieでクライアント、サーバーサイド両方でcookieを扱えるようにする

[universal-cookie](https://github.com/reactivestack/cookies/tree/master/packages/universal-cookie)というcookieを扱うライブラリがあり、それをNuxtと組み合わせて使用するときはサーバーサイドでも正しくcookieを取得、設定できるようにする必要がある。
そこでこの記事ではその設定方法を記載していく。

universal-cookieにはexpressで動作する[universal-cookie-express](https://github.com/reactivestack/cookies/tree/master/packages/universal-cookie-express)というものもある。
Nuxtの設定ではuniversal-cookie-expressのコードを参考に設定してみた。

コードはこちら https://github.com/igayamaguchi/universal-cookie-nuxt-example

## まずはNuxtのインストール
特に変わったことはしていないが、TypeScriptを選択しているので以降に続くサンプルコードはTypeScriptで記述していく。

```shell
$ create-nuxt-app

create-nuxt-app v2.15.0
✨  Generating Nuxt.js project in .
? Project name universal-cookie-nuxt-example
? Project description My first-rate Nuxt.js project
? Author name igayamaguchi
? Choose programming language TypeScript
? Choose the package manager Npm
? Choose UI framework None
? Choose custom server framework None (Recommended)
? Choose the runtime for TypeScript @nuxt/typescript-runtime
? Choose Nuxt.js modules (Press <space> to select, <a> to toggle all, <i> to invert selection)
? Choose linting tools ESLint, Prettier, StyleLint
? Choose test framework Jest
? Choose rendering mode Universal (SSR)
? Choose development tools (Press <space> to select, <a> to toggle all, <i> to invert selection)

$ npm install universal-cookie
+ universal-cookie@4.0.3
```

## 実際に設定をしていく

### 設定していく前にuniversal-cookieの使い方
universal-cookieをdefault importしてインスタンス化、そのインスタンスのメソッドからcookieの操作が可能になる。
この例はcookieの設定と取得。
ただし、サーバーサイドでcookieの設定をしたとしてもクライアントに`Set-Cookie`ヘッダーを送るわけではないのでブラウザがcookieの値を保存することはない。

```typescript
import UniversalCookie from 'universal-cookie'

// set
const cookie = new UniversalCookie()
cookie.set('hoge', 'fuga')

// get
const cookie = new UniversalCookie()
const value = cookie.get('hoge')
console.log(value) // cookieの値 - fuga
```

### Vueコンポーネント内でuniversal-cookieを使えるように
このset、getをVueコンポーネント内で`this.$cookie.set`、`this.$cookie.get`という形でアクセスできるようにする。

以下ページコンポーネントでの使用例。
asyncData、fetchでは`app.$cookie`、それ以外では`this.$cookie`という形でアクセスする。

```vue
<script lang="ts">
import Vue from 'vue'
import Logo from '~/components/Logo.vue'

export default Vue.extend({
  asyncData({ app }) {
    app.$cookie.set('hoge', true)
  },
  fetch({ app }): Promise<void> | void {
    app.$cookie.set('hoge2', true)
  },
  components: {
    Logo
  },
  created() {
    console.log(this.$cookie.get('hoge'))
  },
  mounted() {
    console.log(this.$cookie.get('hoge'))
  }
})
</script>
```

この`this.$cookie`という形を実現するために、Nuxtのplugin機構を活かしてinjectの設定をしていく。
以下プラグインのコード。

```typescript:plugins/cookie
import { OutgoingMessage } from 'http'
import { serialize } from 'cookie'
import { Plugin } from '@nuxt/types'
import UniversalCookie, { CookieChangeOptions } from 'universal-cookie'

declare module 'vue/types/vue' {
  interface Vue {
    $cookie: UniversalCookie
  }
}

declare module '@nuxt/types' {
  interface NuxtAppOptions {
    $cookie: UniversalCookie
  }
}

declare module 'vuex/types/index' {
  interface Store<S> {
    $cookie: UniversalCookie
  }
}

/**
 * Vueでthis.$cookieからクッキーの操作を行える処理を追加する
 * サーバー、クライアント両方で同じインターフェースで扱えるように
 */
const plugin: Plugin = ({ req, res }, inject) => {
  let cookie: UniversalCookie

  if (process.server) {
    cookie = createServerCookie(req.headers.cookie || '', res)
  } else {
    cookie = new UniversalCookie()
  }

  inject('cookie', cookie)
}

/**
 * universal-cookieはサーバーでcookieを追加、変更、削除する場合、それをクライアントに知らせる仕組みがない
 * そのため自前でレスポンスヘッダーにSet-Cookieヘッダーを追加してクッキーの情報をクライアントに送る必要がある
 * その仕組みを備えたインスタンスを生成する
 */
export function createServerCookie(
  cookie: string,
  res: OutgoingMessage
): UniversalCookie {
  const universalCookie = new UniversalCookie(cookie)
  universalCookie.addChangeListener((change: CookieChangeOptions) => {
    if (res.headersSent) {
      return
    }
    let cookieHeader = res.getHeader('Set-Cookie')
    if (typeof cookieHeader === 'string') {
      cookieHeader = [cookieHeader]
    } else if (typeof cookieHeader === 'number') {
      cookieHeader = [cookieHeader.toString()]
    }
    cookieHeader = (cookieHeader as string[]) || []

    // cookieの削除時にはvalueにundefinedが入る
    if (change.value === undefined) {
      cookieHeader.push(serialize(change.name, '', change.options))
    } else {
      cookieHeader.push(serialize(change.name, change.value, change.options))
    }

    res.setHeader('Set-Cookie', cookieHeader)
  })

  return universalCookie
}

export default plugin
```

上から解説していく。

まずはインスタンスの生成。
クライアントサイドでの実行では特にすることもなくそのままuniversal-cookieをインスタンス化。
サーバーサイドではヘッダーに送られているクッキーの文字列を取得してそれを用いてuniversal-cookieをインスタンス化。
`res`をuniversal-cookieのインスタンスを作成する関数に送っているのは、レスポンスヘッダーの取得、設定を行うため。

```typescript
import UniversalCookie from 'universal-cookie'

if (process.server) {
  cookie = createServerCookie(req.headers.cookie || '', res)
} else {
  cookie = new UniversalCookie()
}

// ------------------

function createServerCookie(
  cookie: string,
  res: OutgoingMessage
): UniversalCookie {
  const universalCookie = new UniversalCookie(cookie)

// ------------------
```

universal-cookieには[addChangeListenr](https://github.com/reactivestack/cookies/tree/master/packages/universal-cookie#addchangelistenercallback)というcookieを設定、または削除したときによびだされるフック関数を設定することができるメソッドが備えられている。
サーバーサイドでクッキーを設定する場合はそのフック関数に`Set-Cookie`ヘッダーを返す仕組みを導入することでサーバーで設定したクッキーがブラウザに送信され正しく保存されるようにしている。
フック関数は第一引数に設定されたcookieの値、オプションが渡されるようになっている。

```typescript
universalCookie.addChangeListener((change: CookieChangeOptions) => { /* ... */ })
```

Nuxt上であり得るのか分からないが既にヘッダーが送られ始めた後にcookieの設定をしている場合はサーバーサイドのcookieの設定は行わないようにしている。

```typescript
if (res.headerSent) {
  return
}
```

送る予定のレスポンスの中に既に`Set-Cookie`ヘッダーが設定していた場合その値を取り出して加工していく。
`res.getHeader('xxxxx')`で任意のレスポンスヘッダーを取得することが出来る。今回は`Set-Cookie`ヘッダーを使用するので`res.getHeader('Set-Cookie')`。
`res.getHeader('Set-Cookie')`では配列以外で返ってくるパターンを想定して文字列の配列形式に変換しておく。



```typescript
let cookieHeader = res.getHeader('Set-Cookie')
if (typeof cookieHeader === 'string') {
  cookieHeader = [cookieHeader]
} else if (typeof cookieHeader === 'number') {
  cookieHeader = [cookieHeader.toString()]
}
cookieHeader = (cookieHeader as string[]) || []
```

先ほど作成した配列に設定、または削除するcookieを追加してヘッダーに設定する。
値はフック関数の第一引数に`{ name: クッキー名, value: クッキーの値, options: クッキーのオプション（期限など）}`という形で入っているのでそれを取り出し、`cookie`パッケージのserializeメソッドを使用して文字列に加工してあげることでヘッダーに設定するのに適した文字列となる。

削除時にはoptionsには`{ expires: 1970-01-31T15:00:01.000Z, maxAge: 0 }`のような過去の値、期限切れになるような値が入るようになっているので、それをそのままヘッダーとして送ればブラウザ側のcookieを削除することができる。

`res.setHeader('xxxxx')`でレスポンスヘッダーの追加、更新ができるので、`res.setHeader('Set-Cookie', cookieHeader)`で`Set-Cookie`ヘッダーの設定を行っている。

```typescript
import { serialize } from 'cookie'

// cookieの削除時にはvalueにundefinedが入る
if (change.value === undefined) {
  cookieHeader.push(serialize(change.name, '', change.options))
} else {
  cookieHeader.push(serialize(change.name, change.value, change.options))
 }

res.setHeader('Set-Cookie', cookieHeader)
```

これでプラグインの記述が完成するので、`nuxt.config.ts`にプラグインファイルを読み込む記述を追加してNuxtの設定が完了する。

```typescript
plugins: ['~/plugins/cookie'],
```

## 確認
最初に載せたコードを`page/index.vue`に適用してそのページにアクセスしてみる。

```typescript
<script lang="ts">
import Vue from 'vue'
import Logo from '~/components/Logo.vue'

export default Vue.extend({
  asyncData({ app }) {
    app.$cookie.set('hoge', true)
  },
  fetch({ app }): Promise<void> | void {
    app.$cookie.set('hoge2', true)
  },
  components: {
    Logo
  },
  created() {
    console.log(this.$cookie.get('hoge'))
  },
  mounted() {
    console.log(this.$cookie.get('hoge'))
  }
})
</script>
```

アクセスしてChromeの開発ツールのNetworkタブでページのレスポンスヘッダーを除いてみると以下のように`Set-Cookie`ヘッダーが設定されていることが確認できる。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64362/9cddef05-2a29-b053-cd0e-a78c8d6eba03.png)

またCookieも正しく設定されていることが確認できる。

Networkタブの中のCookie
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64362/206ec473-2320-f14c-93ec-9957ff175b6f.png)

ApplicationタブのCookies
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64362/c32af9cd-bd1d-7eea-da96-1a33e3b27d35.png)

値の設定だけではなくもちろん値の取得も機能している。
以下のコードのconsole.logが正しく出力されている。

```typescript
created() {
  console.log(this.$cookie.get('hoge'))
},
mounted() {
  console.log(this.$cookie.get('hoge'))
}
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64362/65a3d73a-c793-e625-9063-c1e4deb65882.png)

この情報が誰かの役に立てば幸いです。