kun432's blog

Alexaなどスマートスピーカーの話題中心に、Voiceflowの日本語情報を発信してます。たまにAWSやkubernetesなど。

〜スマートスピーカーやVoiceflowの記事は右メニューのカテゴリからどうぞ。〜

クロスサイトなAjaxアプリケーションをNetlify+Herokuで作ってみた

f:id:kun432:20220131013030p:plain

Voiceflow Dialog Management APIを使って、Webサイトチャットボットのバックエンドを作ろうとして、CORSとか色々ハマりました。Webアプリとかちゃんと作ったことないので私が単に知らなさすぎただけというのはありますが、とても良い勉強になったのでメモ。

目次

発端

WebチャットボットのUIをイチから作るの、スキルもないし、ちょっとしんどいなーと思ってたところで以下を見つけました。

チャットUIのいろいろな表現に対応しやすいし、Webサイトへの組み込みもかんたん、APIクライアント用意するのはVoiceflowとつなぎこむためのAPIサーバだけ、ということで、やりたかったことにとてもマッチしてます。そこで、マルチターンの会話に必要なセッション管理処理を追加して、まずはローカルで試してみたのが以下です。

セッション管理にはexpress-sessionを使って、発行されたセッションIDをそのままVoiceflow Dialog Management APIのState APIのユーザIDとして使っています。あんまり良いやり方ではないですが、とりあえずマルチターンの会話がいい感じにできていますよね。

そう、かんたんにちゃんと動くんですよ、localhostでは

これをインターネット上のPaaSあたりで動かそうと思うとうまく行かないわけです。なぜかというとこのあたり。

ということで、この辺をまずは理解するために、Voiceflowのことは一旦忘れて、シンプルなSPAで試してみました。

サンプル

サンプルはこういう感じです。

f:id:kun432:20220131115330p:plain

  • フロントエンドはNetlifyに配置。上記で紹介されている@riversun/chatuxのチャットボットUIフレームワークを使う。
  • バックエンドはHerokuに配置。express-sessionでセッション管理してカウントアップして、上記チャットボットにあわせたレスポンスを返す。

コードは以下にあります。

まだちゃんと理解ができていないのですが、それぞれ別々に説明してみます。

バックエンド

バックエンドコードはこんな感じです。

const express = require('express')
const session = require('express-session')
const morgan = require('morgan');
const app = express()

const FRONTEND_URL = process.env.FRONTEND_URL; 

const sess = {
  secret: 'secret_key',
  resave: false,
  saveUninitialized: false,
  cookie: {
    maxAge: 60000,
    sameSite: "none",
    secure: true,
    httpOnly: true,
  }
}
app.set('port', (process.env.PORT || 3000));
app.use(morgan('combined'));
app.set('trust proxy', 1) // trust first proxy
app.use(session(sess))
app.use(function (req, res, next) {
  res.header('Access-Control-Allow-Origin', `${FRONTEND_URL}`);
  res.header('Access-Control-Allow-Method', 'GET, POST, HEAD, OPTIONS');
  res.header('Access-Control-Allow-Headers', 'Origin, Content-Type, Accept');
  res.header('Access-Control-Allow-Credentials', true);
  next();
});
app.post('/chat', (req, res) => {
  console.log(`sessionID: ${req.sessionID}, maxAge: ${req.session.cookie.maxAge}, counter: ${req.session.counter}`)

  const response = {output: []};
  const msg = response.output;

  let message = req.session.hasOwnProperty('counter') ? '' : '新しいセッションを開始しました。<br /><br />'
  req.session.counter = req.session.hasOwnProperty('counter') ? req.session.counter + 1 : 1
  message += `session ID: ${req.sessionID}<br />expires: ${req.session.cookie.expires}<br />maxAge: ${req.session.cookie.maxAge}<br /> counter: ${req.session.counter}`

  msg.push({
    type: 'text',
    value: message
  });

  res.json(response);
})

app.listen(app.get('port'), () => console.log('listening on port ' +  app.get('port')));

メインとなるカウンター処理は以下の部分です。ここは今回の本題とは少しずれますので詳細は割愛しますが、セッションを使ってカウンターをインクリメント、chatuxに合わせたレスポンスを返すだけです。

app.post('/chat', (req, res) => {
  console.log(`sessionID: ${req.sessionID}, maxAge: ${req.session.cookie.maxAge}, counter: ${req.session.counter}`)

  const response = {output: []};
  const msg = response.output;

  let message = req.session.hasOwnProperty('counter') ? '' : '新しいセッションを開始しました。<br /><br />'
  req.session.counter = req.session.hasOwnProperty('counter') ? req.session.counter + 1 : 1
  message += `session ID: ${req.sessionID}<br />expires: ${req.session.cookie.expires}<br />maxAge: ${req.session.cookie.maxAge}<br /> counter: ${req.session.counter}`

  msg.push({
    type: 'text',
    value: message
  });

  res.json(response);
})

重要なのはこのあたりでした。

...snip...
const sess = {
  secret: 'secret_key',
  resave: false,
  saveUninitialized: false,
  cookie: {
    maxAge: 60000,
    sameSite: "none",
    secure: true,
    httpOnly: true,
  }
}
...snip...
app.use(function (req, res, next) {
  res.header('Access-Control-Allow-Origin', `${FRONTEND_URL}`);
  res.header('Access-Control-Allow-Method', 'GET, POST, HEAD, OPTIONS');
  res.header('Access-Control-Allow-Headers', 'Origin, Content-Type, Accept');
  res.header('Access-Control-Allow-Credentials', true);
  next();
});

CORS対策として、Access-Control-Allow-MethodAccess-Control-Allow-Headersを指定するのは必須ですが、セッションでCookieを使う場合、

  • Access-Control-Allow-Originでアクセス元Originを指定する必要がある。ワイルドカードは使えない。
  • Access-Control-Allow-CredentialsをTrueにする

も必要になります。(後述するクライアント側の対応も必要)

ちな、Access-Control-Allow-Originについては、Herokuで環境変数設定すると便利です、Heroku Buttonでデプロイするときに設定するような感じで使えるので。

express-sessionのcookie設定については、クロスサイトになるのでsameSite: "none"が必要になりますが、これだけでは動かず。以下の設定は必須でした。

  • secure: true
  • httpOnly: true

これを設定しない場合、ブラウザからCookieヘッダーが送られてこなかったです(chromeのデベロッパーツールで見てた)。この辺の設定は、やったほうががいいというのはなんとなく理解できるものの、送られてこない理屈がわかりませんでした。もう少し追いかけてみたい。

あとHeroku等で動かす場合はこの辺も必要になると思ってます。HerokuでNode.jsアプリを立ち上げると、待ち受けるポートは動的に割り当てられて、インターネット側には443で公開されるので。ただ、この辺が設定されてなくて動くかどうかまでは確認してないです。

app.set("trust proxy", 1);

フロントエンド

フロントエンドは抜粋で。

<script src="https://chatux-kun432.netlify.app/chatux.cores.js"></script>
<script>

    const chatux = new ChatUx();

    const opt = {
        renderMode: 'auto',
        buttonOffWhenOpenFrame: false,
        bot: {
            wakeupText: "start",
            botPhoto: 'img/robot.png',
            humanPhoto: 'img/human.png',
            widget: {
                sendLabel: '送信',
                placeHolder: 'なにか入力してください。'
            }
        },
        api: {
            endpoint: 'https://foo.herokuapp.com/chat', // herokuアプリのURLに変更する
            method: 'POST',
            dataType: 'json',
            errorResponse: {
                output: [
                    {type: 'text', value: 'エラーが発生しました。'}
                ]
            },

...snip....

    chatux.init(opt);

    let isAutoStartChat = true;
    if (chatux.isMobileDevice()) {
        isAutoStartChat = false;
    }

    chatux.start(isAutoStartChat);

</script>
...snip....

chatuxの初期化パラメータは、公式レポジトリのpublicにあるサンプルと基本的に同じです。当然ながらAPIのエンドポイントはherokuで作成したバックエンドに向ける必要があります。

...snip....
            endpoint: 'https://foo.herokuapp.com/chat', // herokuアプリのURLに変更する
...snip....

一番重要なのはここ

...snip....
<script src="https://chatux-kun432.netlify.app/chatux.cores.js"></script>
...snip....

AjaxクライアントでクロスサイトでCookieを使う場合、クライアント側の対応も必要になります。具体的には、

  • XMLHttpRequestの場合は、withCredentialstrueを設定する
  • Fetch APIの場合はcredentialsに'include'```を設定する

が必要になります。

Qiitaの記事にある、

<script src="https://riversun.github.io/chatux/chatux.min.js"></script>

や、自分でchatuxのレポジトリをクローンしてwebpackでコンパイルした場合、どうやらこれに対応してないようでした。

chatuxのソースを追いかけていくと、ここにあります。

https://github.com/riversun/chatux/blob/master/src/ajax-client.js#L65

...snip....
    _handleJson(reqParam) {
        const asyncResult = new AjaxResult();
        const fetchParam = {
            method: reqParam.method,
            mode: 'cors',
            cache: 'no-cache',
            //credentials:null,// 'include',    // ここ
            //referrer: 'no-referrer',
        };
...snip....

ここを

...snip....
            credentials:'include',
...snip....

にしてあげて、webpackで再度コンパイルすると有効になったjsファイルが作成されます。

webpack.config.jsをコピーしてwebpack.cores.config.jsを作成。以下を修正。

...snip....
      filename: argv.mode === 'production' ? `[name].cores.js` : `[name].js`,
...snip....

package.jsonも上記を使うscriptの設定を追加

...snip....
  "scripts": {
...snip....
    "release": "set NODE_ENV=test&&webpack --config webpack.config.js --mode production",
    "releasec": "set NODE_ENV=test&&webpack --config webpack.config.cores.js --mode production"   //追加
  },
...snip....

npm runするとdistディレクトリにchatux.cores.jsが作成されるので、これをどこかにおいて使えばよいです。私の場合はこちらもNetlifyで別のサイトを作ってあげました。

$ npm rub releasec
$ ls dist
chatux.min.js    chatux.cores.js

実際に動いているもの

こんな感じで動きます。ちゃんとカウンターが上がっているのがわかるかと思います。

f:id:kun432:20220131135922g:plain

コードや使い方は以下にあります。Heroku Buttonでかんたんに試せるようにしてます。

github.com

余談

PCのChromeあたりでは問題なかったのですが、iOSのChromeやSafariのデフォルトだとどうやらCookieが保存されないようで、カウントアップしません。

f:id:kun432:20220131180518j:plain

iOSではデフォルトでWebサイト超えトラッキングが有効化されており、これによりうまく行かないようです。設定を変更する必要がある点にご注意ください。

f:id:kun432:20220131181221j:plainf:id:kun432:20220131181237j:plain

qiita.com

まとめ

HerokuやNetlifyとあまり関係ない記事になってしまいました。WebアプリケーションもSPAもちゃんとやったことがなくて、知らないことも多かったので色々苦労しましたが、よい勉強になりました。これでVoiceflow Dialog Managament APIを使ったWebチャットボットに向けていろいろ準備ができたかなと思います。

あとは、セッション管理周りはRedisを使ったり、あとState APIじゃなくてStateless APIのほうが良さげなので、そのへんも含めてもう少し調べてみたいと思います。

その他参考