SST Ion + AWS IoT + Next.jsでMQTT over WebSocketチャットアプリを作る

## なにやるの？

AWSサーバーレスWebSocket第3の選択肢、AWS IoTで簡単なチャットアプリを作るよ。

IaCにはSST Ion、フロントエンドはNext.jsで、スタイルの調整にはTailwind CSSを使う。

最終的なコードだけ見たい人は下記参照。

https://github.com/gel1123/sst-ion-realtime-chat

## 前提: SSTについて

SST Ionっていうのは、サーバーレスアーキテクチャに特化したAWS等のIaC＆開発キットだよ。

https://ion.sst.dev/docs/

安定版のSST（v2）に対して、SST Ionは最新開発版（v3）の立ち位置になっていて、v2とIon（v3）との間には次のような大きな変化があるよ。

- CDKをやめてPulumiベースになった
- もともと簡単なコードが、さらに簡易化された
- CDKをやめた、つまりCloudFormationへの依存がなくなったことで、AWSだけでなくCloudflareのIaCとしての機能も持ち始めた

安定版のSST（v2）はGitHubのスター数がなんとAWS CDKの2倍も獲得している。別リポジトリのSST Ionは歴が浅いからまだそこまでのスター数にはなっていないのだけど、こちらは更新ペースがかなり勢いよくて、これからが注目のOSSだよ。

## 前提: AWSでのサーバーレスWebSocketについて

AWSでサーバーレスなWebSocketを構成するなら、通常は次の２つの選択肢がある。

- AppSync
- API Gateway WebSocket API

ただし前者はGraphQL前提であるという縛りがあるし、後者は接続管理に一手間要る（connectionIdをDBに記録して管理しないといけない）という課題がある。

そこで登場するのがAWS IoT。AWS IoT Coreが提供するMQTT over WebSocketを使えば、上記の課題をクリアできる。それに今回使うSST Ionなら、AWS IoT CoreのMQTT over WebSocketを限りなく少ない手数で構成できる。

早速実装していくよ。

## Next.js + AWS IoT CoreをSST IonのRealtimeコンポーネントで構築して、簡単なチャットアプリを作ってみる

### 手順1. SST IonのCLIを準備

まずはSST IonのCLIをインストールする。インストール済みの人は、もし必要ならアップグレードしておいてね。

```zsh
# SST Ion CLIのインストール
curl -fsSL https://ion.sst.dev/install | bash

# SST Ion CLIのアップグレード
sst upgrade

# 現在のバージョン確認（執筆時点では0.0.473）
sst version
```

### 手順2. Next.js with SST Ionを構築

SST IonのCLIを用意できたら、早速ターミナルから次のコマンドを実行していくよ。

```zsh 
bunx create-next-app@latest sst-ion-realtime-chat
✔ Would you like to use TypeScript? … Yes
✔ Would you like to use ESLint? … Yes
✔ Would you like to use Tailwind CSS? … Yes
✔ Would you like to use `src/` directory? … No
✔ Would you like to use App Router? (recommended) … Yes
✔ Would you like to customize the default import alias (@/*)? … Yes

cd sst-ion-realtime-chat
sst init


   ███████╗███████╗████████╗
   ██╔════╝██╔════╝╚══██╔══╝
   ███████╗███████╗   ██║
   ╚════██║╚════██║   ██║
   ███████║███████║   ██║
   ╚══════╝╚══════╝   ╚═╝

>  Next.js detected. This will...
   - create an sst.config.ts
   - modify the tsconfig.json
   - add the sst sdk to package.json

✓  Template: nextjs

✓  Using: aws

✓  Done 🎉
```

これでNext.jsをSST IonとリンクしながらAWS上で動かせるようになった（SSTはローカルにありながら実際のAWS上のリソースとしてコードを動かせる）。

早速dev modeを起動しておこう。

```zsh
bun dev
```

### 手順3. AWS IoT Coreを組み込む

次にAWS IoT Coreを組み込む。

```ts:sst.config.ts
/// <reference path="./.sst/platform/config.d.ts" />

export default $config({
  app(input) {
    return {
      name: "sst-ion-realtime-chat",
      removal: input?.stage === "production" ? "retain" : "remove",
      home: "aws",
    };
  },
  async run() {
    // AWS IoTを用いたリアルタイム通信用のリソースを作成
    const realtime = new sst.aws.Realtime("ChatRealtime", {
      // どのトピックに誰がアクセスできるかの制御を行うLambda関数を指定
      authorizer: "authorizer.handler",
    });
    // Next.jsを作成
    new sst.aws.Nextjs("ChatWeb", {
      // 下記でリンクすることで、指定されたリソースを操作する権限等を与える
      link: [realtime],
    });
  },
});
```

```ts:authorizer.ts
import { Resource } from "sst";
import { realtime } from "sst/aws/realtime";

export const handler = realtime.authorizer(async (token) => {
  const prefix = `${Resource.App.name}/${Resource.App.stage}`;

  // ルーム機能など実装を拡張して、ユーザーごとに送信・受信可能な条件を限定していくなら、
  // ここにトークンの検証などの実装を施していく。
  // （今回は雛形なので実装なし）

  return {
    publish: [`${prefix}/*`],
    subscribe: [`${prefix}/*`],
  };
});
```

### 4. フロントエンドを実装して、MQTT over WebSocketでチャットできるようにする

最後にフロントエンドを用意すれば完成！

AWS IoT関連のライブラリをインストールした上で、下記を実装しよう。

```zsh
bun i aws-iot-device-sdk-v2
```

```tsx:components/chat.tsx
"use client";

import { useEffect, useState } from "react";
import { iot, mqtt } from "aws-iot-device-sdk-v2";

function createConnection(endpoint: string, authorizer: string) {
  const client = new mqtt.MqttClient();
  const id = window.crypto.randomUUID();

  return client.new_connection(
    iot.AwsIotMqttConnectionConfigBuilder.new_with_websocket()
      .with_clean_session(true)
      .with_client_id(`client_${id}`)
      .with_endpoint(endpoint)
      .with_custom_authorizer("", authorizer, "", "PLACEHOLDER_TOKEN")
      .build(),
  );
}

export default function Chat({
  topic,
  endpoint,
  authorizer,
}: Readonly<{
  topic: string;
  endpoint: string;
  authorizer: string;
}>) {
  const [messages, setMessages] = useState<string[]>([]);
  const [connection, setConnection] =
    useState<mqtt.MqttClientConnection | null>(null);

  useEffect(() => {
    const _connection = createConnection(endpoint, authorizer);

    // 接続確立時の処理
    _connection.on("connect", async () => {
      try {
        await _connection.subscribe(topic, mqtt.QoS.AtLeastOnce);
        setConnection(_connection);
      } catch (e) {}
    });

    // メッセージ受信時の処理
    _connection.on("message", (_fullTopic, payload) => {
      const message = new TextDecoder("utf8").decode(new Uint8Array(payload));
      setMessages((prev) => [...prev, message]);
    });

    // エラー時の処理
    _connection.on("error", console.error);

    // 接続開始
    _connection.connect();

    // コンポーネントがアンマウントされた際に接続を切断
    return () => {
      _connection.disconnect();
      setConnection(null);
    };
  }, [topic, endpoint, authorizer]);

  return (
    <div className="mx-auto flex max-h-screen min-h-screen w-96 flex-col gap-4 p-4">
      <div className="flex flex-grow flex-col gap-4 overflow-y-scroll rounded-lg border p-4">
        {connection &&
          messages.length > 0 &&
          messages.map((msg, i) => (
            <div key={i} className="border-b pb-2 leading-tight">
              {msg}
            </div>
          ))}
      </div>
      <form
        className="flex gap-2"
        onSubmit={async (e) => {
          e.preventDefault();

          const input = (e.target as HTMLFormElement).message;

          connection!.publish(topic, input.value, mqtt.QoS.AtLeastOnce);
          input.value = "";
        }}
      >
        <input
          className="flex-grow rounded-lg border bg-transparent p-2 text-sm"
          required
          autoFocus
          type="text"
          name="message"
          placeholder={connection ? "なんでも話してね" : "接続中です"}
        />
        <button
          className="rounded-lg border p-2 text-sm font-medium"
          type="submit"
          disabled={connection === null}
        >
          送信
        </button>
      </form>
    </div>
  );
}
```

```tsx:app/page.tsx
import Chat from "@/components/chat";
import { Resource } from "sst";

const topic = "sst-chat";

export default function Home() {
  return (
    <Chat
      endpoint={Resource.ChatRealtime.endpoint}
      authorizer={Resource.ChatRealtime.authorizer}
      topic={`${Resource.App.name}/${Resource.App.stage}/${topic}`}
    />
  );
}
```

これでごく簡単なチャットアプリができた！

![スクリーンショット 2024-07-01 21.15.04.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/267156/7bef256b-e114-6217-5b9a-79bb77d77a57.jpeg)

AWS上に本番デプロイするなら下記をするだけでOK。

```zsh
# prodステージにデプロイするなら
sst deploy --stage prod

# SST ❍ ion 0.0.473  ready!
# 
# ➜  App:        sst-ion-realtime-chat
#    Stage:      prod
# 
# ~  Deploying
# 
# ---- 中略 ----
# 
# ✓  Complete
#    ChatWeb: https://d2kgfc8tbsgetm.cloudfront.net
```

本番デプロイ後のフロントエンドのURLは、sst deploy完了時にターミナルに出力されているから、そこからアクセスすればいいよ。

これで簡単なチャットアプリは完成。同じURLに別のデバイスやブラウザでアクセスすると、双方でリアルタイムにチャットをやり取りできるのが分かると思う。

今回はチャットアプリの雛形だからここまで！

これをベースに、個別のチャットルーム機能を実装したり、データベースにメッセージを保存したり、ユーザー管理を行うようにしたりと機能を追加していくと、もうちょっと本格的なチャットアプリとして運用できるようになるね。

最後に、デプロイしたリソースを破棄にしたくなった時のコマンドを書いておくよ。

```
# デフォルトのステージ名のリソースを消す時
sst remove

# ステージ名を指定する時（本番環境のステージ名を指定）
sst remove --stage prod
```

## おまけ: Tailwind CSSのクラス名ソート

何気なく使ったTailwind CSSに関して、クラス名をソートしたいなら手っ取り早いのがTailwind CSS公式のPrettierプラグインだよ。ESLint側のTailwind CSSプラグインも好まれているみたいだけど、記事を書く時みたいに「最短の手数でソートしたい」ならPrettierプラグイン側の方がおすすめかな。

記事中のコードも、これでTailwind CSSのクラス名ソートをしてある。

https://tailwindcss.com/blog/automatic-class-sorting-with-prettier

```zsh
bun i -D prettier prettier-plugin-tailwindcss
```

```.prettierrc
{
  "plugins": ["prettier-plugin-tailwindcss"]
}
```

## 著者紹介

沖縄でAWS界隈を中心に色々作ってるIT技術者です。Kenpal株式会社で開発に携わっているほか、個人事業として人日ベースの従量課金ですぐに使える日報SaaS「RevisNote」を運営しています。無料プランもあるので、日報や社内SNSに相当する使い方ができるサービスを探している方はぜひ使ってみてください。

https://revisnote.com/