Nuxt x FirebaseでもSSRするときはNow (ZEIT)がオススメ

NuxtでSSRしたい!と思って、Cloud FunctionsとFirebase Hostingを利用していたものの、作り込んでいくにつれてどんどん複雑に…。
試しにNowを使ってみたところ一気にコードがスッキリしたのでご報告。

Cloud Functionsを使ったSSRってどうやるの?

Functions (+ Hosting)でSSRができれば、静的ファイルはHosting、動的の箇所だけFunctionsが応答してくれるのってスゴイ!もうサーバのことを考えなくていいのでは?と思いました。

こちらの方法は少し調べれば色々と出てきます。以下が一例です。

私もはじめはこちらなどを参考に構築していたのですが、次第に複雑に。

どこが複雑になったの?

実装の際に気になったところ、苦労した点をいくつか。

Nuxt(標準)サーバがFunctions上で動くわけではない

Nuxtを導入すると、yarn devで開発用のサーバが立ち上がり、yarn build && yarn startでNuxtをProduction用でビルドしてサーバが立ち上がります。
Functionsではこのサーバではなく、ExpressサーバのモジュールとしてNuxtを読み込ませて使うようになります。
そうなるとserverMiddlewareを開発用とFunctions用で作り変える必要がでてきます。

例えばNuxtの/api/articlesで、記事の一覧を返却するAPIを作ろうとしたら、

nuxt.config.ts

import NuxtConfiguration from '@nuxt/config'
import bodyParser from 'body-parser'
const cookieParser = require('cookie-parser')

const config: NuxtConfiguration = {
  ...
  serverMiddleware: [
    bodyParser.json(),
    cookieParser(),
    {
      path: '/api/articles',
      handler: '~/serverMiddleware/api/articles',
    },
  ],
  ...
}

export default config

のようなコードで実現することができます。
ですが、これをFunctionsにデプロイする際には、

functions/src/index.ts

import * as functions from 'firebase-functions'
const cookieParser = require('cookie-parser')
import { Nuxt } from 'nuxt'

import api from './serverMiddleware/api'

const express = require('express')
const app = express()

const nuxt = new Nuxt({
  dev: false,
  buildDir: '.nuxt',
  build: {
    publicPath: '/assets/',
  },
})

const handleRequest = (req, res) => {
  res.set('Cache-Control', 'public, max-age=300, s-maxage=600')
  return new Promise((resolve, reject) => {
    nuxt.render(req, res, (promise: Promise<any>) => {
      promise.then(resolve).catch(reject)
    })
  })
}

app.use(cookieParser())
app.post('/api/articles', api.articles)
app.use(handleRequest)

export const ssr = functions.https.onRequest(app)

のように、APIをバラしてExpressのルーティングとして登録し直さなければ動作しません。

また、ミドルウェアで利用するreq, resの引数も同一のものではないため、

serverMiddleware/api/articles.ts - Nuxtの場合

export default async (req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/plain' })
  res.end('SUCCESS')
}

functions/src/serverMiddleware/api/articles.ts - Functions (Express)の場合

export default async (req, res) => {
  res.status(200).send('SUCCESS')
}

のように一部のコードを変更する必要があり、もし同一コードで動作させるには軽いラッパーをかませる必要があったりもしました。

utils/adres.ts

export default (res, status: number, message: string) => {
  if (res.hasOwnProperty('status')) {
    res.status(status).send(message)
  } else {
    res.statusCode = status
    res.setHeader('Content-Type', 'text/plain')
    res.end(message)
  }
}

// import adres from './utils/adres'
// adres.send(res, 200, 'SUCCESS') のように使います

試してはないのですが、このあたりはNuxtサーバを利用せずに、元々Expressで開発をしていたらある程度解決できるのかもしれません。

Functionsの package.json が荒れる

Functions上でNuxtを利用するために、メインアプリケーションの依存パッケージをFunctionsのpackage.jsonにすべて含める必要があります。
そのため実際にFunctionsで利用している依存ファイルが分かりづらくなってしまいます。

functions/package.json

{
  ...
  "dependencies": {
    "@immutable-array/prototype": "^1.0.4",
    "@nuxtjs/axios": "^5.3.6",
    "body-parser": "^1.18.3",
    "cookie": "^0.3.1",
    "cookie-parser": "^1.4.4",
    "date-fns": "^1.30.1",
    "express": "^4.16.4",
    "firebase": "^5.8.2",
    "firebase-admin": "~7.0.0",
    "firebase-functions": "^2.2.0",
    "lodash": "^4.17.11",
    "nuxt": "^2.4.3",
    "nuxt-ts": "latest",
    "uuid": "^3.3.2",
    "vue-i18n": "^8.8.2",
    "vue-property-decorator": "^7.3.0"
  },
  ...
}

functions/package.json - Nuxtを含めない場合

{
  ...
  "dependencies": {
    "axios": "^0.18.0",
    "firebase": "^5.8.2",
    "firebase-admin": "~7.0.0",
    "firebase-functions": "^2.2.0",
    "lodash": "^4.17.11"
  },
  ...
}

サーバ環境がFunctionsの機能に依存する

Nuxt x Functionsでログインを行うサンプルでは『ログインはクライアント側のみで処理をする』というのがよくありますが、SSRの利点を活かすのであればログインはバックエンド側でも行っておきたいはずです。
なぜ多くのサンブルがクライアント側のみでログイン処理を行っているかというと、バックエンド側でのログイン処理は一気に複雑になってしまうからですね…。

Nuxt (SSR)をFunctionsを使ってバックエンドでログイン処理を行うには、以下の記事が参考になりそうです。

実現したい流れとしては、以下のようになります。

  1. Firebaseのログイン時に発行される idToken からセッションCookieを作成して Cookie (__session) に保存
  2. ブラウザからのアクセスに対して上記セッションCookieから権限の確認をする
  3. 確認できた場合、ログイン情報をreq.userなどに収納し、nuxtServerInit({ commit }, { req })からStoreに保存

そこでCookie (session) を使ってみよう!と思ったときに、なぜか使えない…ということが起こりました。
調べてみると、Functions (+ Hosting)ではFunctionsのCookieは (__session) しか使えない。とのことです。

上記のようにログイン時のセッションのみを利用する場合は__sessionを使えばよいのですが、画面の状態や言語情報を保存しておくなど、クライアント側で値を保持しておきたい場合のCookieを利用することができません。
そのため、Cookieを利用するような情報は『ログインしている時のみ利用できる』という制限のもと、Firestorageなどに保存しておき、バックエンド側で取得してから使う必要がありそうです。

Functionsのエミュレート環境が本番と異なる

firebase serveでFunctionsを再現するエミュレート環境が立ち上がりますが、

  • エミュレート環境ではうまく動いているけど本番でエラーが起きる
  • 本番ではうまく動いているけどでエミュレート環境でエラーが起きる

というようなことが頻繁に起きます。
そのたびに依存ファイルを確認したり、コードの書き方を変更したりと解決までに時間がかかることがあります。

コードやファイルのコピーコマンドが必要になる

Nuxtで利用しているコードやyarn buildで出力したファイルを、Hosting用/Functions用に適切な位置にコピーする必要があり、そのためのコマンドを別に用意する必要があります。
また、コードがNuxtとFunctionsで共通でない場合は、一部のコードを置換するなどの処理も必要になってきます。

Nowを使うとどうなる?

nowコマンドを叩くだけで、Nowのサーバ上でyarn build && yarn startが実行され、開いたポートを公開してくれます。
本番でもローカルと同様にNuxtサーバが起動し、Functionsの不要なパッケージは削除でき、Cookieも自由に使え、ファイルをコピーするコマンドも不要となります。

独自ドメインの利用は有料 ($0.99) で、Nowのネームサーバを利用するようにしておけばOK。
nowコマンドでの公開直後はusagizmocom-79xlsjcvx.now.shのような適当なURLが振られているので、now ln usagizmocom-79xlsjcvx.now.sh usagizmo.comでドメインと紐付けて公開完了。
now.jsonalias: ["usagizmo.com"]を指定していればnow --target productionでドメインの紐付けまでやってくれたりの細かい設定はNowのドキュメントにて。

注意点としては以下の3点になります。

  1. nuxt-ts: 2.4.3を利用すること
    最新の Nuxt (v2.5.1) はTypeScriptに対応しているのですが、Nowでのビルド時にエラーが出てしまいました。修正されるまで待ちましょう。
  2. Now (v1) を利用すること
    NuxtのSSRはまだ v2 に対応していないようです。
  3. Now v1 を使用している場合の注意でNow (v1) が消滅することが示唆されています。
    早く v2 がNuxt (SSR)に対応してくれることを祈っています…。

Now (v1)の状況が少し怖いですが、Functions (+ Hosting)でSSRをせずに別のサーバでNuxtを立ち上げる方針で進めていこうと思っています。

ちなみにこのサイト (usagizmo.com) は Now (v2) の静的ビルド (@now/static-build) で公開しています。

package.json

{
  ...
  "scripts": {
    ...
    "generate": "nuxt-ts generate",
    "now-build": "yarn generate",
    ...
  }
  ...
}

now.json

{
  "version": 2,
  "builds": [{ "src": "package.json", "use": "@now/static-build" }],
  "alias": ["usagizmo.com"]
}

静的ビルドの場合はFirebase Hostingだけでもよいのですが、のちにコメント機能や掲示板の設置とかも考えていますので!

ではまた。