denoとapi gateway with websocketを使ったリアルタイムチャットを作ってみる(fresh, appRunnerも)

api gatewayがwebsocket向けに利用できるという事を知ったのと、個人的にwebsocketの知識が足りていないのと、denoのFreshというFWを触ってみたかったのとあって、やってみることにしました。あと使った事なかったのでApp Runnerも使っておきます。

# 使いたいサービス、技術

- Lambda
- api gateway
- deno
- Fresh
- sam cli
- App　Runner
- Docker
- DynamoDB

だいたいこんなところでしょうか。

# 事前準備

このあたりを参考にして進めてみます。

https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/websocket-api-chat-app.html

これをベースにしつつFreshでチャットを作ってみようかと思います。

で、

> このチュートリアルの完了には約 30 分かかります。

とありまして、ボリューミーなんだろうなと思うのでこのへんは要点だけ残しておくことにします。

## チュートリアル: WebSocket API、Lambda、DynamoDB を使用したサーバーレスチャットアプリケーションの構築

1. ステップ 1: Lambda 関数と DynamoDB テーブルを作成する

チュートリアル通りに進めました

![スクリーンショット 2022-11-03 19.33.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308709/24ec3769-2d3f-37a2-1cfe-1e633a4e1d00.png)

2. ステップ 2: WebSocket API を作成する

`1.`で作ったLambdaを各ルートに割り当てました

![スクリーンショット 2022-11-03 19.40.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308709/eab6c1c7-f9ca-e4f9-5602-b86464aee5dc.png)

3. ステップ 3: API をテストする

まず、 `wscat`が入ってなかったのでnpmでinstallします。

```$ npm install -g wscat```

### connect (2つのターミナルで)

```wscat -c wss://abcdef123.execute-api.us-west-2.amazonaws.com/production```

### sendmessage

端末1で送ったメッセージが無事端末2に届きました。

![スクリーンショット 2022-11-03 20.12.36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308709/2f25b360-b3a9-5d90-2ee1-ccbfe86447fe.png)

![スクリーンショット 2022-11-03 20.12.45.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308709/dc2135fa-ce50-4b06-c7d6-a1ac2b6ab1a3.png)

## 完了

そんなわけで無事完了しました。

## 作成したLambda

cloudFormationにて作成したLambdaを確認しておきます。

### connect handler

eventとして受け取ったIDをconnectionIdとしてDynamoDBに保存してるだけっぽいです。

```npm
          const AWS = require('aws-sdk');
                const ddb = new AWS.DynamoDB.DocumentClient();
                exports.handler = async function (event, context) {
                  try {
                    await ddb
                      .put({
                        TableName: process.env.table,
                        Item: {
                          connectionId: event.requestContext.connectionId,
                        },
                      })
                      .promise();
                  } catch (err) {
                    return {
                      statusCode: 500,
                    };
                  }
                  return {
                    statusCode: 200,
                  };
                };
```

### delete

逆にdeleteはconnectionIdを削除してるだけ
```npm
          const AWS = require('aws-sdk');
                const ddb = new AWS.DynamoDB.DocumentClient();
                
                exports.handler = async function (event, context) {
                  await ddb
                    .delete({
                      TableName: process.env.table,
                      Key: {
                        connectionId: event.requestContext.connectionId,
                      },
                    })
                    .promise();
                  return {
                    statusCode: 200,
                  };
                };
```

### send message

少し処理が長くなるけども難しいことはやってない。

1. dbをscanしてconnectioIdのリストを作成
2. eventとして受け取ったmessageをapi gatewayのendpointにpostする

ApiGatewayManagementApiのpostToConnectionにConnectionIdとMessageを渡せばいいっぽい。

```
          const AWS = require('aws-sdk');
                const ddb = new AWS.DynamoDB.DocumentClient();
                
                exports.handler = async function (event, context) {
                  let connections;
                  try {
                    connections = await ddb.scan({ TableName: process.env.table }).promise();
                  } catch (err) {
                    return {
                      statusCode: 500,
                    };
                  }
                  const callbackAPI = new AWS.ApiGatewayManagementApi({
                    apiVersion: '2018-11-29',
                    endpoint:
                      event.requestContext.domainName + '/' + event.requestContext.stage,
                  });
                
                  const message = JSON.parse(event.body).message;
                
                  const sendMessages = connections.Items.map(async ({ connectionId }) => {
                    if (connectionId !== event.requestContext.connectionId) {
                      try {
                        await callbackAPI
                          .postToConnection({ ConnectionId: connectionId, Data: message })
                          .promise();
                      } catch (e) {
                        console.log(e);
                      }
                    }
                  });
                
                  try {
                    await Promise.all(sendMessages);
                  } catch (e) {
                    console.log(e);
                    return {
                      statusCode: 500,
                    };
                  }
                
                  return { statusCode: 200 };
                };
```

### default

送信した本人にpostToConnectionしてるだけに見える

```
          const AWS = require('aws-sdk');

                exports.handler = async function (event, context) {
                  let connectionInfo;
                  let connectionId = event.requestContext.connectionId;
                
                  const callbackAPI = new AWS.ApiGatewayManagementApi({
                    apiVersion: '2018-11-29',
                    endpoint:
                      event.requestContext.domainName + '/' + event.requestContext.stage,
                  });
                
                  try {
                    connectionInfo = await callbackAPI
                      .getConnection({ ConnectionId: event.requestContext.connectionId })
                      .promise();
                  } catch (e) {
                    console.log(e);
                  }
                
                  connectionInfo.connectionID = connectionId;
                
                  await callbackAPI
                    .postToConnection({
                      ConnectionId: event.requestContext.connectionId,
                      Data:
                        'Use the sendmessage route to send a message. Your info:' +
                        JSON.stringify(connectionInfo),
                    })
                    .promise();
                
                  return {
                    statusCode: 200,
                  };
                };
```

そんなわけでwebsocket用のLambdaはけっこうシンプル。connectionIdさえあればメッセージを送れるので、送る処理が別のシステムやサービスでも良さそう。

# チャットサービスの作成

## Fresh環境の構築

Freshはdenoのフレームワークで最もメジャーなもの(2022年現在)かと思います。
あ、denoは・・誤解を恐れつつ言うとnodejsの新しいやつです。

https://deno.land/
https://fresh.deno.dev/

### install

#### deno

mac
`brew install deno`

なお、入ってたのが古かったので`deno upgrade`で更新しました。

#### fresh

```deno run -A -r https://fresh.deno.dev websocket-example-apigw-with-fresh```

で完了です。
Tailwind CSSはusing, VS Codeはnotにしました。

```bash
% cd websocket-example-apigw-with-fresh 
% deno task start
```

であっさり起動して、localhost:8000で確認できます。

![スクリーンショット 2022-11-03 22.16.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308709/1bdde018-23f8-544d-d467-33d64773b346.png)

## websocketを使ってみる

今回、serverの方はいいんだけど、これ使うとserverもclientも対応できるみたい。
https://deno.land/x/websocket@v0.1.4

```tsx:islands/Chat.tsx
import { WebSocketClient, StandardWebSocketClient } from "https://deno.land/x/websocket@v0.1.4/mod.ts";
const endpoint = "wss://xxxxxx.execute-api.ap-northeast-1.amazonaws.com/production";
const ws: WebSocketClient = new StandardWebSocketClient(endpoint);

ws.on("open", function() {
  console.log("ws connected!");
  ws.send("something");
});
ws.on("message", function (message: string) {
  console.log(message);
});

export default function Chat() {
  return (
    <div class="flex gap-2 w-full">
      <p class="flex-grow-1 font-bold text-xl">Hello</p>
    </div>
  );
}
```

を作って、`routes/index.tsx`を軽く追記します。
`import Chat from "../islands/Chat.tsx";`
と
`<Chat />`
だけ。

すると、terminalからメッセージを送ったものがブラウザのコンソールに出てくるのが確認できました。

![スクリーンショット 2022-11-03 22.36.33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308709/e287beaf-86b5-d17b-b20a-b9cd22492fce.png)

websocketを使うのは問題なさそう。
ちなみに`routes/index.tsx`がトップページ。routesの中に階層つきでpage毎のファイルを作成するのが基本っぽい？`[name].tsx`みたいなのはワイルドカードみたいなやつっぽいけど`[`使われるとbashでの使い勝手悪いなぁ。

## deno on Docker

denoはAppRunnerで動かそうかと思います。
よってDocker化する必要があります。

https://fresh.deno.dev/docs/concepts/deployment#docker

↑ここにあるDockerfileを作るだけでOKでした。

```Dockerfile
FROM denoland/deno:1.25.0

ARG GIT_REVISION
ENV DENO_DEPLOYMENT_ID=${GIT_REVISION}

WORKDIR /app

COPY . .
RUN deno cache main.ts --import-map=import_map.json

EXPOSE 8000

CMD ["run", "-A", "main.ts"]
```

docker-compose.ymlも用意します。

```yaml:docker-compose.yml
version: '3'

services:
  deno-fresh:
    build:
      context: .
      dockerfile: Dockerfile
    ports: 
     - "8000:8000"
    volumes:
     - ./:/app
```

ディレクトリ構成とかは手抜き。ちゃんとやるときはソースとそれ以外はわけましょう。

```bash
% docker-compose up --build
```

これで8000番ポートにて無事起動しました。
ではチャットをやりとりするUIを作成していきます。

## チャットUI

とはいえ、UIに関してはセンスがないので良さげでシンプルなものを~~パク~~…インスパイアします。

https://dev.to/briosheje/sample-todoapp-in-fresh-deno-framework-4ohi
https://github.com/briosheje/Fresh-Deno-Mongo-Docker-Todoapp

こちらはmongodbを使ったtodoアプリっぽいですね。

Chat.tsxはこんな感じになりました。
あまりReact的な書き方に慣れていないので、ツッコミどころ多いかもしれませんが。
というかファイルはtsxなんですが、中身はjsxですね。ここでは気にしないでいきます。

```tsx: Chat.tsx
import { useState } from "preact/hooks";
import { WebSocketClient, StandardWebSocketClient } from "https://deno.land/x/websocket@v0.1.4/mod.ts";
import { tw } from "@twind";
const endpoint = "wss://xxxxxx.execute-api.ap-northeast-1.amazonaws.com/production";
const ws: WebSocketClient = new StandardWebSocketClient(endpoint);

ws.on("open", function() {
  console.log("ws connected!")
});

export default function Chat() {
  const [messages, setMessages] = useState([])
  const [comment, setComment] = useState("")
  function addMessage(message) {
    console.log(message)
    setMessages([...messages, message.data])
  }

  ws.on("message", function (message: string) {
    addMessage(message)
  });

  function sendMessage() {
    ws.send(JSON.stringify({ "action": "sendmessage", "message": comment}))
    // 自分で送ったメッセージはws経由でcallbackされないので直接追加
    addMessage({ data: comment })
  }

  function viewMessages() {
    const renderHtml = []
    messages.forEach((m) => {
      renderHtml.push(<>{m}<hr /></>)
    })
    return <div>{ renderHtml }</div>
  }

  return (
    <div class="flex gap-2 w-full">
      <div
        className={tw`bg-white rounded shadow p-6 m-4 w-full lg:w-3/4 lg:max-w-lg`}
      >
        <div className={tw`mb-4`}>
         {viewMessages()}
        </div>
        <div className={tw`mb-4`}>
          <input
            className={tw`shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline`}
            onChange={(e) => setComment(e.currentTarget.value)}
            id="chat-comment"
            type="text"
            name="comment"
          />
        </div>
        <button
          onClick={() => sendMessage()}
          className={tw`bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline`}
        >
          Send Message
        </button>
      </div>
    </div>
  );
}
```

そしてとりあえずリアルタイムでチャットという部分を満たしただけのものができました。

![スクリーンショット 2022-11-23 18.39.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308709/861a71ce-dc55-7c38-2e7e-7ffe31b5beac.png)

## AppRunner

さてこれをAppRunnerでdeployしてみます。
denoにもdeployの仕組みがあるらしく、それでいいじゃんって思いますが、AppRunner使ってみたかったのです。
.envとか使わずにendpoint直書きしちゃってて、githubに丸ごと上げられないですしね。

AppRunnerは今回、コンソールで進めちゃいます。

### まずはECR

コードはECRにアップしちゃう事にします。

privete repositoryをひとまず作ります。(CLIでも可)

![スクリーンショット 2022-11-23 18.45.30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308709/b3aea3c4-c172-6256-3542-391b55e7bf63.png)

あとはawsのコンソールに記載されている通りにECRにpushしていきます。

```
% aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin 00000000.dkr.ecr.us-east-1.amazonaws.com
% docker build -t websocket-example-apigw-with-fresh .
% docker tag websocket-example-apigw-with-fresh:latest 00000000.dkr.ecr.us-east-1.amazonaws.com/websocket-example-apigw-with-fresh:latest
% docker push 00000000.dkr.ecr.us-east-1.amazonaws.com/websocket-example-apigw-with-fresh:latest
```

これで無事、アップできました。

![スクリーンショット 2022-11-23 18.49.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308709/db2e375a-9be7-e7aa-bd12-8ee2f7396091.png)

これをAppRunnerで使っていきます。

### AppRunnerの設定

![スクリーンショット 2022-11-23 18.50.30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308709/78644903-c6e3-b2e9-6b0b-2655a049d3dd.png)

deployはautomatic、role作成はお任せしちゃいました。どうせすぐ消すし。

![スクリーンショット 2022-11-23 18.51.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308709/a8aec24d-e73d-b645-48d2-bc003d81664f.png)

portは8000としています。その他はデフォルト

![スクリーンショット 2022-11-23 18.52.51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308709/b0ff8e8f-1070-aba9-4561-385e1c94312d.png)

### deploy完了

![スクリーンショット 2022-11-23 19.02.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308709/5f0b359d-42e3-8f60-d083-75a588842f2d.png)

無事、deployが完了しました。ECRにあげてしまえばあとはほぼワンアクションでしたね。便利。

## そんなわけで完成！

無事動きましたー！

![99y4h-vnj86.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308709/5de968e9-9c07-b17d-5bb5-7789eac18219.gif)

chromeとfirefoxでやりとりしてます。

ソースはこちらにアップしております。
https://github.com/ikegam1/websocket-example-apigw-with-fresh

これまでちゃんとwebsocketを理解できてない部分がありましたが、これで　`完全に理解` できた気がします。
今回は蛇足だったけどApp Runnerも便利で良い。