API Gateway(WebSocket API)のバックエンドをRailsで実装する で作成したAPI GatewayにWebSocketで接続してメッセージのやり取りが出来るようにします。
仕様は、Action CableでWebSocketを試す と同じ状態を目指します。

先にまとめ

・WebSocketClientは使えなかったので、W3CWebSocketを使用した。
・再接続は自前でする必要がある。
・ActionCableではされていたヘルスチェックが、W3CWebSocketではされない。
 ゆえに、一時的な通信断では切断されない。自前で表示を変えないと繋がってそうに見える。
・10分(デフォルト)を超えるか、サーバー側で切断した場合は切断される。
・PCがスリープすると切断される(Chromeの挙動かな?)

パッケージ追加

今回は、NuxtでAction Cableに接続したソースを使います。
https://dev.azure.com/nightonly/nuxt-app-origin/_git/nuxt-app-origin/commit/acd46282fc63edfe7fa4ecbd96810bec667927ec

% yarn add websocket

フロント実装

ダサいけど、component名はMessages2にしました。
(接続先やライブラリ名ではなく、機能名にしたいので)

pages/index.vue

<template>
  <div>
    <v-row>
      <v-col v-if="!$auth.loggedIn" cols="12" md="6">
        <SignUp />
      </v-col>
      <v-col cols="12" md="6">
        <Infomations />
      </v-col>
    </v-row>
    <v-row>
      <v-col>
        <Messages />
      </v-col>
    </v-row>
+    <v-row>
+      <v-col>
+        <Messages2 />
+      </v-col>
+    </v-row>
  </div>
</template>

<script>
import SignUp from '~/components/index/SignUp.vue'
import Infomations from '~/components/index/Infomations.vue'
import Messages from '~/components/index/Messages.vue'
+ import Messages2 from '~/components/index/Messages2.vue'

export default {
  name: 'Index',
  components: {
    SignUp,
    Infomations,
    Messages,
+    Messages2
  }
}
</script>

components/index/Messages2.vue を作成

<template>
  <div>
    <v-card>
      <Processing v-if="loading || connecting || processing || $nuxt.isOffline" />

Action Cableと異なり、ヘルスチェックされない。
通信断でも表示は変わらないので、「$nuxt.isOffline」でネットワークが切れたら、くるくるを表示するようにしました。
メッセージは接続後に受信されるし、送信も繋がるまで送信中にできるので実害はないですが、受信されない状態である事を明示する為。

      <v-card-title>新着メッセージ (WebSocket/API Gateway)</v-card-title>
      <v-card-text class="pb-0">
        <article v-if="loading">
          取得中...
        </article>
        <article v-else-if="lists.length === 0">
          メッセージはありません。
        </article>
        <template v-else>
          <article v-for="list in lists" :key="list.id">
            <v-divider class="mb-2" />
            <div v-if="list.id == null" class="mb-2">
              〜〜 {{ list.unsent_count.toLocaleString() }}件のメッセージが取得できませんでした 〜〜
            </div>
            <div v-else class="mb-2">
              <v-chip small :color="(list.channel === 'all_channel') ? 'green' : ''">{{ list.channel_i18n }}</v-chip>
              {{ (readMore[list.id]) ? list.body : list.body.substr(0, 255) + ((list.body.length > 255) ? '...' : '') }}
              <span class="text-nowrap">
                <a v-if="list.body.length > 255 && !readMore[list.id]" @click="$set(readMore, list.id, true)">▼続きを読む</a>
                <a v-if="list.body.length > 255 && readMore[list.id]" @click="$set(readMore, list.id, false)">▲少なくする</a>
                ({{ list.created_at_i18n }})
              </span>
              <span v-if="list.user != null" class="text-nowrap">
                <v-avatar size="24px">
                  <v-img :src="list.user.image_url.mini" />
                </v-avatar>
                {{ list.user.name }}
              </span>
            </div>
          </article>
        </template>
      </v-card-text>
      <v-divider />
      <v-card-actions>
        <div v-if="authError" class="my-2">
          認証に失敗しました。ページを更新してください。
        </div>
        <div v-else-if="systemError" class="my-2">
          処理に失敗しました。しばらく時間をあけてから、やり直してください。
        </div>
        <div v-else-if="!$auth.loggedIn" class="my-2">
          ※ログインするとメッセージを送信できるようになります。
        </div>
        <div v-else class="d-flex my-2">
          <v-select
            id="message_channel"
            v-model="channel"
            :items="channels"
            return-object
            label="送信先"
            dense
            outlined
            filled
            :loading="processing"
            hide-details="false"
            class="v-select-size"
          />
          <v-text-field
            id="message_body"
            v-model="body"
            label="メッセージ"
            autocomplete="off"
            dense
            outlined
            filled
            :loading="processing"
            class="mx-1"
            hide-details="false"
            style="width: 38vw"
          />
          <v-btn class="mt-1" color="primary" :disabled="connecting || processing || !channel || !body" @click="onSendMessage()">送信</v-btn>
        </div>
      </v-card-actions>
    </v-card>
  </div>
</template>

<script>
import Application from '~/plugins/application.js'

export default {
  name: 'IndexMessages2',
  mixins: [Application],

  data () {
    return {
      connecting: true,
      processing: false,
      socket: null,
      authIntervalID: null,
      authError: false,
      systemError: false,
      pageClose: false,
      token: null,
      lists: [],
      readMore: {},
      channel: null,
      channels: [],
      body: ''
    }
  },

  async created () {
    await this.$axios.post(this.$config.apiBaseURL + this.$config.wsTokenNewUrl)
      .then((response) => {
        if (response.data == null) {
          return this.$toasted.error(this.$t('system.error'))
        } else {
          this.token = response.data.token
          this.channels = response.data.channels
          if (response.data.channels.length > 0) { this.channel = response.data.channels[0] }
        }
      },
      (error) => {
        if (error.response == null) {
          return this.$toasted.error(this.$t('network.failure'))
        } else if (error.response.data == null) {
          return this.$toasted.error(this.$t('network.error'))
        } else {
          return this.$toasted.error(this.$t('system.error'))
        }
      })

    this.connectWebSocket()
  },

  // ページ遷移 or 閉じる -> 切断 // Tips: NuxtLinkだと切断されない為、明示的する必要がある
  destroyed () {
    // eslint-disable-next-line no-console
    if (this.$config.debug) { console.log('== [API Gateway]destroyed') }

    this.pageClose = true
    if (!this.authError) { this.socket.close() }
  },

  methods: {
    // WebSocket接続
    connectWebSocket () {
      // eslint-disable-next-line no-console
      if (this.$config.debug) { console.log('== [API Gateway]connectWebSocket') }

      const self = this
      const W3CWebSocket = require('websocket').w3cwebsocket
      this.socket = new W3CWebSocket(this.$config.wsApiGatewayURL)

WebSocketClientも試しましたが、エラーになる為、深掘りせずに断念。

      var WebSocketClient = require('websocket').client;
      var client = new WebSocketClient();
> TypeError: WebSocketClient is not a constructor

      // 接続成功 or 再接続 -> メッセージ取得(認証含む)
      this.socket.onopen = function () {
        // eslint-disable-next-line no-console
        if (self.$config.debug) { console.log('== [API Gateway]onopen') }

        if (self.lists.length === 0) {
          self.socket.send(JSON.stringify({ action: 'default', method: 'get_messages', token: self.token, limit: 5 }))
        } else {
          self.socket.send(JSON.stringify({ action: 'default', method: 'get_messages', token: self.token, last_id: self.lists[self.lists.length - 1].id }))
        }
      }

      // 接続失敗 or 通信断 or 離脱 or 認証失敗 -> 表示切り替え # Tips: 一時的な通信断ではcloseされない(デフォルトのクォータ: 10分)
      this.socket.onclose = function () {
        // eslint-disable-next-line no-console
        if (self.$config.debug) { console.log('== [API Gateway]onclose') }

        if (self.authIntervalID != null) {
          clearInterval(self.authIntervalID)
          self.authIntervalID = null
        }
        if (self.pageClose) { return } // Tips: 離脱時は再接続しないようにする

        if (!self.authError) {
          self.connecting = true
          setTimeout(self.connectWebSocket(), 1000 * 5) // Tips: 5秒後に再接続。PCスリープでも切断される

再接続は自前でする必要がある。
一時的な通信断では切断されない。
10分(デフォルト)を超えるか、サーバー側で切断した場合は切断される。
意外だったのは、PCがスリープすると切断される(Chromeの挙動かな?)

        }
      }

      // 認証・送信結果受信 or メッセージ受信 -> 表示切り替え
      this.socket.onmessage = function (e) {
        // eslint-disable-next-line no-console
        if (self.$config.debug) { console.log('== [API Gateway]onmessage', e) }

        let data = []
        if (e.data !== '') { data = JSON.parse(e.data) }

        // eslint-disable-next-line no-console
        if (data.alert != null) { console.log(data.alert) }

        if (data.action === 'auth_result') {
          if (self.authIntervalID != null) {
            clearInterval(self.authIntervalID)
            self.authIntervalID = null
          }
          if (data.success !== true) {
            self.authError = true
            self.socket.close()
          } else {
            self.authIntervalID = setInterval(self.authRequest, 1000 * 60 * 15) // Tips: 15分毎
          }
        } else if (data.action === 'send_result') {
          if (data.success !== true) {
            alert(data.alert)
          } else {
            self.body = ''
          }
        } else if (!['get_messages', 'send_message'].includes(data.action) || data.success !== true) {
          // eslint-disable-next-line no-console
          console.log('undefined action[' + data.action + '] or success[' + data.success + ']')
          self.systemError = true
        } else {
          if (data.unsent_count > 0) {
            self.lists.push({ id: null, unsent_count: data.unsent_count })
          }
          self.lists = self.lists.concat(data.messages)
        }

        self.processing = false
        self.connecting = false
        self.loading = false
      }
    },

    // 認証チェック
    authRequest () {
      // eslint-disable-next-line no-console
      if (this.$config.debug) { console.log('== [API Gateway]authRequest') }
      this.socket.send(JSON.stringify({ action: 'default', method: 'auth_request', token: this.token }))
    },

    // メッセージ送信
    onSendMessage () {
      // eslint-disable-next-line no-console
      if (this.$config.debug) { console.log('== [API Gateway]onSendMessage: ' + this.channel.value + ', ' + this.body) }

      if (!this.channels.some(channel => channel.value === this.channel.value) || this.body === '') {
        // eslint-disable-next-line no-console
        console.log('undefined channel[' + channel + '] or empty body[' + body + ']')
        return
      }

      this.processing = true
      this.socket.send(JSON.stringify({ action: 'default', method: 'send_message', channel: this.channel.value, body: this.body, token: this.token }))
    }
  }
}
</script>

<style scoped>
.v-select-size >>> .v-select__selections input {
  width: 4px;
}
.text-nowrap {
  white-space: nowrap!important;
}
</style>

config/development.js

module.exports = {
  debug: true,
  envName: '【開発環境】',
  apiBaseURL: 'http://localhost:3000',
  frontBaseURL: 'http://localhost:5000',
  wsBaseURL: 'ws://localhost:3000',
+  wsApiGatewayURL: 'wss://test-ws.example.com/development'
}

コミットしたURLはサンプルなので、環境に合わせて変更してください。

その他:Jestが落ちてた

これはactioncableのパッケージですが。

% yarn test
 FAIL  test/page/index.spec.js
  ● Test suite failed to run

    TypeError: Cannot set property 'ActionCable' of undefined

      81 |
      82 | <script>
    > 83 | import ActionCable from 'actioncable'
         | ^
      84 | import Application from '~/plugins/application.js'
      85 |
      86 | export default {

importをrequireに変更して解消。

components/index/Messages.vue

- import ActionCable from 'actioncable'
+       const ActionCable = require('actioncable')
        this.consumer = ActionCable.createConsumer(this.$config.wsBaseURL + this.$config.wsCableUrl)

今回のコミット内容
https://dev.azure.com/nightonly/nuxt-app-origin/_git/nuxt-app-origin/commit/2cfad729095ece029be3d924483610069d7e0029

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です