DynamoDB Stream + WebSocket API + ElectroDBでチャット開発RTA始めるよ

## 何やるの？

- DynamoDB Stream + API Gateway WebSocket APIで
- チャット開発を爆速で行い、
- ついでに記事も書くよ

## 前提

```zsh
# 筆者のPCはmac
% node -v
v18.18.0
% sst version
0.0.279
```

## 「急いでるから最終的なコードだけ見せて欲しい」という人に

コードの全体像はこれだよ

https://github.com/gel1123/chat-rta

## 用語説明

### WebSocket

WebのことはとりあえずMDN読もう。これだよ

https://developer.mozilla.org/ja/docs/Web/API/WebSockets_API

噛み砕いて説明すると、WebSocketはサーバとクライアント間でのリアルタイム＆双方向通信を可能にするプロトコルだよ。WebSocketを使えばサーバからのデータをクライアントに即座に送信して、リアルタイムで動くアプリケーション——もっともイメージのつきやすいの例えばチャットとかだね——を作ることができるよ。

### WebSocket API

今回の記事では、AWSが提供するAPI構築サービス「API Gateway」のWebSocket APIのことを示しているよ。名前の通りWebSocket通信可能なエンドポイントを構築するサービスで、今回のチャットアプリの肝になっているよ。

### DynamoDB

AWSの代表的なNoSQLデータベースだよ。

https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/Introduction.html

NoSQLにはFirestoreのようなドキュメント志向のものと、DynamoDBのようなキーバリュー志向のDBがあって、それぞれ強いところと弱いところが違っているよ。DynamoDBはキー設計とインデックス設計の癖が強いけど、その代わり高速でスケーラブル。オンライン対戦機能のあるゲームのような性能が求められる場面でも使われているよ。DynamoDBの魅力は例えば、以下のGameBusiness.jpさんの記事でも紹介されているね。

https://www.gamebusiness.jp/article/2023/07/31/22007.html

### DynamoDB Streams

そんな優れたキーバリューストアであるDynamoDBの変更を検知してリアルタイムで他のサービスに通知してくれるのが、DynamoDB Streamsだよ。

https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/Streams.html

今回のチャットアプリでは、DynamoDBへのチャットメッセージ投稿時にLambda関数をトリガーして、WebSocketに接続しているクライアントにそのメッセージを送信するということをDynamoDB Streamsを用いて実現するよ。

### Lambda関数

AWSの代表的なサーバレスサービスだよ。イベント駆動の関数実行サービスで、AWSを使うなら至る所で登場するよ。サービスとサービスの繋ぐ接着剤みたいなものだね。今回のチャットアプリでは、API GatewayのWebSocket APIにトリガーされるLambda関数を一つと、DynamoDB StreamsにトリガーされるLambda関数を一つ。合計２つ用意するからね。

### ElectroDB

JavaScript, TypeScriptでDynamoDBを操作したい開発者のためのORMだよ。単一テーブル設計でDynamoDBを扱うことに特化していて、同一テーブル内で効率よくクエリを発行できるようエンティティを定義できるのが魅力的だね。

https://electrodb.dev/en/core-concepts/introduction/

DynamoDBの単一テーブル設計（Single Table Design）については、下記の記事が詳しく紹介してくれているから興味のある人は読んでみてね。

https://aws.amazon.com/jp/blogs/news/single-table-vs-multi-table-design-in-amazon-dynamodb/

## チャット開発RTA開始にあたって用意するもの

- SST Ion
- Pulumi
- Bun

フロントエンドはなんでもいいや！　とりあえず今回はSST Ionなら手っ取り早く用意できるAstroを使う。
レイアウトはできる限り最短でいきたいから、Tailwind CSSで。

SST Ion + Astroは以前記事を書いたので、興味ある人は読んでね

https://qiita.com/faable01/items/f66f78497fc08e0b532c

SST IonとBunのインストール方法も再掲。

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

Windowsユーザ向けのインストール方法はそれぞれ下記参照だよ。

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

https://bun.sh/docs/installation

それからaws configureも実施しておいてね。そこまで出来てるなら、さっそくやっていこう

## 準備

SST Ion + Astroプロジェクトを立ち上げるよ。各コマンドの実行中に聞かれる質問には、適宜好みの回答をしてね。ただ、最初のコマンドで作成するAstro雛形プロジェクトのルートディレクトリでsst initしたいから、astroプロジェクトの展開パスについての質問には、カレントディレクトリ `.` を答えるといいよ。

（もしカレントディレクトリに展開しないなら、作成したAstroプロジェクトのルートディレクトリに移動してから、二つ目以降のコマンドを実行していこう）

```zsh
# Astro雛形
bun create astro@latest
# AstroへのTailwind CSS統合
bunx astro add tailwind
# Astro構成のSST ion初期化
sst init

# パッケージ導入
bun i -D  @pulumi/aws @pulumi/pulumi @tailwindcss/forms @tailwindcss/typography @types/aws-lambda @types/uuid
bun i uuid @aws-sdk/client-apigatewaymanagementapi @aws-sdk/client-dynamodb @aws-sdk/util-dynamodb electrodb
```

次は設定ファイルを少しいじろう。Astroの設定ファイルに、Tailwind CSSを統合するよう記述して、それからServer Routeも定義する。

```js:astro.config.mjs
import { defineConfig } from "astro/config";
import aws from "astro-sst";
import tailwind from "@astrojs/tailwind";

export default defineConfig({
  output: "server",
  adapter: aws({
    deploymentStrategy: "regional",
    // https://github.com/sst/ion/issues/196
    serverRoutes: ["api/*"],
  }),
  integrations: [tailwind()],
});
```

それからTailwind CSSの設定ファイルに、フォームのデフォルトスタイルを提供する@tailwindcss/formsと、文書をいい感じにスタイリングしてくれる@tailwindcss/typographyをプラグインとして組み込もう。

```js:tailwind.config.mjs
/** @type {import('tailwindcss').Config} */
export default {
  content: ["./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}"],
  theme: {
    extend: {},
  },
  plugins: [require("@tailwindcss/typography"), require("@tailwindcss/forms")],
};
```

最後にプロジェクト内のimportパスがややこしくならないよう、tsconfig.jsonのcompilerOptionsを定義。

```json:tsconfig.json
{
  "extends": "astro/tsconfigs/strict",
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}
```

これで準備OK。まずはインフラを構築しよう

## SST IonとPulumiでチャットインフラ（DynamoDB Streams + Lambda + WebSocket API）構築

SST init時にsstによるAWSインフラ定義がsst.config.tsに生成されている。最初はAstroのみだから、ごくシンプルな内容だね。ひとまずAstroコンポーネントのリソース名をわかりやすいものに変更しておこう。

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

export default $config({
  app(input) {
    return {
      name: "chat-rta",
      removal: input?.stage === "production" ? "retain" : "remove",
      home: "aws",
    };
  },
  async run() {
    // ひとまず名前だけ変えておいた
    new sst.aws.Astro("RtaChatWeb");
  },
});
```

今からここにDynamoDBやLambda、WebSocket API等のインフラを定義していくけど、その前にターミナルで早速開発モードでの起動を始めてしまおう。初回デプロイは少し時間がかかるので、今のうちに流しておきたい。

```zsh
bun dev
```

SST Ionが開発環境をAWS上にデプロイしている間に、さっそくインフラを構成していく。SST Ionはかなり短い記述でAWSのインフラを定義できるのが魅力だけど、まだ発展途上だから足りないコンポーネントもそれなりにある。けどPulumiを完全サポートしてるから、SST Ionになくて足りないリソースはPulumiから引っ張ってきちゃおう。

今回作成するリソースはこんな感じ（実際のコードもこの順番で定義していくからね）

- **DynamoDB:** SST IonのDynamoコンポーネントを使う
- **WebSocket APIがトリガーするLambda:** SST IonのFunctionコンポーネントを使う
- **WebSocket API:** PulumiのApiコンポーネントを使う
- **DynamoDB StreamsがトリガーするLambda:** SST IonのFunctionコンポーネントを使う
- **Astro:** SST IonのAstroコンポーネントを使う

早速実装しよう。

```ts:sst.config.ts
/// <reference path="./.sst/platform/config.d.ts" />
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";

export default $config({
  app(input) {
    return {
      name: "chat-rta",
      removal: input?.stage === "production" ? "retain" : "remove",
      home: "aws",
    };
  },
  async run() {
    // https://ion.sst.dev/docs/providers/#functions
    const current = await aws.getCallerIdentity({});
    const accountId = current.accountId;
    const region = (await aws.getRegion({})).name;

    // ---- DynamoDB ----
    const table = new sst.aws.Dynamo("RtaChatTable", {
      fields: {
        pk: "string",
        sk: "string",
      },
      primaryIndex: { hashKey: "pk", rangeKey: "sk" },
      stream: "new-image",
    });

    // ---- WebSocket Handler ----
    const wsHandler = new sst.aws.Function("RtaChatWsHandler", {
      handler: "src/ws.handler",
      link: [table],
    });

    // ---- WebSocket API ----
    const wsApi = new aws.apigatewayv2.Api("RtaChatWsApi", {
      protocolType: "WEBSOCKET",
      routeSelectionExpression: "$request.body.action",
    });
    const connectIntegration = new aws.apigatewayv2.Integration(
      "RtaChatConnectIntegration",
      {
        apiId: wsApi.id,
        integrationType: "AWS_PROXY",
        integrationUri: wsHandler.arn,
        integrationMethod: "POST",
        payloadFormatVersion: "1.0",
      }
    );
    const connectRoute = new aws.apigatewayv2.Route("RtaChatConnectRoute", {
      apiId: wsApi.id,
      routeKey: "$connect",
      authorizationType: "NONE",
      target: pulumi.interpolate`integrations/${connectIntegration.id}`,
    });
    const disconnectIntegration = new aws.apigatewayv2.Integration(
      "RtaChatDisconnectIntegration",
      {
        apiId: wsApi.id,
        integrationType: "AWS_PROXY",
        integrationUri: wsHandler.arn,
        integrationMethod: "POST",
        payloadFormatVersion: "1.0",
      }
    );
    const disconnectRoute = new aws.apigatewayv2.Route(
      "RtaChatDisconnectRoute",
      {
        apiId: wsApi.id,
        routeKey: "$disconnect",
        authorizationType: "NONE",
        target: pulumi.interpolate`integrations/${disconnectIntegration.id}`,
      }
    );
    const stage = new aws.apigatewayv2.Stage("RtaChatWsApiStage", {
      apiId: wsApi.id,
      name: $app.stage,
      autoDeploy: true,
    });
    const wsInvokePermission = new aws.lambda.Permission(
      "RtaChatWsInvokePermission",
      {
        action: "lambda:InvokeFunction",
        function: wsHandler.arn,
        principal: "apigateway.amazonaws.com",
        sourceArn: pulumi.interpolate`${wsApi.executionArn}/*/*`,
      }
    );

    // ---- DynamoDB Streams Handler ----
    const subscriber = table.subscribe({
      handler: "src/trigger.handler",
      link: [table],
      environment: {
        WS_API_URL: wsApi.apiEndpoint,
        STAGE: $app.stage,
      },
      permissions: [
        {
          actions: ["execute-api:ManageConnections"],
          resources: [
            pulumi.interpolate`arn:aws:execute-api:${region}:${accountId}:${wsApi.id}/${$app.stage}/*`,
          ],
        },
      ],
    });

    // ---- WebSite (Astro) ----
    new sst.aws.Astro("RtaChatWeb", {
      link: [table],
      environment: {
        WS_API_URL: wsApi.apiEndpoint,
        STAGE: $app.stage,
      },
    });
  },
});
```

インフラ定義はこれでOK。定義上に、Lambda関数ハンドラー２つのパスを書いているけど、これは後で実装するからね。

ここで実装したインフラ定義についていくつかの要点について軽く説明しようね。

### routeKey: `$connect` と `$disconnect` について

WebSocket APIのルート定義で、routeKeyにそれぞれ `$connect` と `$disconnect` を指定したよね。 `$connect` と `$disconnect` は、AWS API Gateway の WebSocket API における特別なルートだよ。

WebSocket通信セッションの開始と終了を管理するために使われるルートで、クライアントが WebSocket エンドポイント URL に ws:// または wss:// を使用して接続を試みると、API Gateway は自動的に `$connect` ルートをトリガーするようになっているよ。

逆に切断時には、自動的に $disconnect ルートがトリガーされる。

後でこれらのルートがトリガーするLambdaを実装するときにやるけど、WebSocketは接続時に、接続ごとに一意のconnectionIdを発行するよ。今回のチャットアプリでは、後でメッセージの送信を行う時のために、このconnectionIdを `$connect` トリガー時にDynamoDBへ保存するようにするつもり。逆に `$disconnect` 時にはDynamoDBから消すようにするわけだね。

### aws.apigatewayv2.Stage

API Gatewayのステージを作成するコンポーネントだよ。API Gatewayのステージは、デプロイメントの特定のスナップショットを指すもので、実際のエンドポイントとして機能するんだ。バージョン管理や環境の分割をしやすい仕組みになっているんだね。

今回は[SST Ionのグローバルコンテキスト](https://ion.sst.dev/docs/reference/global/)からSSTアプリケーションとしてのデプロイ先ステージ名と、API Gatewayのステージ名が同じになるようにしているよ。sst deploy後にすぐ最新のコードが動いて欲しいから、autoDeployも有効化してる。

### pulumi.interpolate

`pulumi.interpolate` は、Pulumiでリソースの出力や設定値を含む文字列を動的に構築するために使用されるよ。リソースが持つプロパティのうち、非同期的に生成される値（例えば、あるリソースの作成後にのみ分かるURLやIDなど）を取得して、別のリソースの設定に使用する時にこれがあると便利だよ。

### execute-api:ManageConnections

DynamoDB StreamsがトリガーするLambdaに許可しているアクションだよ。これを許可することで、DynamoDB StreamsがトリガーするLambdaが、別リソースであるWebSocket APIの操作をできるようにしているよ。

今回のチャットアプリでは、DynamoDB Streamsが新たなチャットメッセージをキャプチャして、それをWebSocket API経由でクライアントに配信したいからね。この権限許可は必須だよ。

________________________

インフラ定義コード `sst.config.ts` の要点説明はこんなところかな。

次はLambda関数ハンドラー実装の前に、まずElectroDBを用いてDynamoDB上で扱うエンティティ定義を行おう。

## ElectroDBを使って、DynamoDB上のエンティティ定義

冒頭の用語説明でも軽く紹介したけど、ElectroDBは単一テーブル設計でDynamoDBを操作するための強力なライブラリだよ。

https://electrodb.dev/en/core-concepts/introduction/

今回はElectroDBを用いて、接続情報を管理するエンティティと、メッセージを管理するエンティティの２つを用意する。

```ts:src/dynamo.ts
export * as Dynamo from "./dynamo";

import { type EntityConfiguration } from "electrodb";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { Resource } from "sst";

export const Client = new DynamoDBClient({});

export const Configuration: EntityConfiguration = {
  table: Resource.RtaChatTable.name,
  client: Client,
};
```

```ts:src/entities/connection.ts
import { Dynamo } from "@/dynamo";
import { Entity, type EntityItem } from "electrodb";

/** 接続情報エンティティ */
export const ConnectionEntity = new Entity(
  {
    model: {
      version: "1",
      entity: "Connection",
      service: "rta-chat",
    },
    attributes: {
      /** 接続ID */
      connectionId: {
        type: "string",
        required: true,
        readOnly: true,
      },
    },
    indexes: {
      connections: {
        pk: {
          field: "pk",
          composite: [],
        },
        sk: {
          field: "sk",
          composite: ["connectionId"],
        },
      },
    },
  },
  Dynamo.Configuration
);

export type ConnectionEntityType = EntityItem<typeof ConnectionEntity>;
```

```ts:src/entities/message.ts
import { Dynamo } from "@/dynamo";
import { Entity, type EntityItem } from "electrodb";

/** メッセージエンティティ */
export const MessageEntity = new Entity(
  {
    model: {
      version: "1",
      entity: "Message",
      service: "rta-chat",
    },
    attributes: {
      /** メッセージID */
      messageId: {
        type: "string",
        required: true,
        readOnly: true,
      },
      /** 作成日時 */
      createdAt: {
        type: "number",
        required: true,
        readOnly: true,
      },
      /** メッセージ本文 */
      body: {
        type: "string",
        required: true,
        readOnly: false,
      },
    },
    indexes: {
      orderByCreatedAt: {
        pk: {
          field: "pk",
          composite: [],
        },
        sk: {
          field: "sk",
          composite: ["createdAt", "messageId"],
        },
      },
    },
  },
  Dynamo.Configuration
);

export type MessageEntityType = EntityItem<typeof MessageEntity>;
```

これで必要なエンティティ定義は出揃った。次はAPI Gateway WebSocket APIとDynamoDB StreamsがトリガーするLambda関数ハンドラーを定義しよう

## Lambda関数ハンドラーの定義

まずはWebSocket APIがトリガーするLambda関数ハンドラー。接続エンティティを作成したり消したりするだけだから、割とやってることはシンプル。ElectroDBは定義したエンティティごとに作成や削除など様々な操作処理が生えるので、そいつらを使ってやります。

```ts:src/ws.ts
import { ConnectionEntity } from "@/entities/connection";
import type { APIGatewayProxyWebsocketHandlerV2 } from "aws-lambda";

export const handler: APIGatewayProxyWebsocketHandlerV2 = async (event) => {
  const connectionId = event.requestContext.connectionId;
  const routeKey = event.requestContext.routeKey;

  switch (routeKey) {
    // 接続ルート
    case "$connect":
      await ConnectionEntity.create({ connectionId }).go();
      return {
        statusCode: 200,
        body: "Connected",
      };
    // 切断ルート
    case "$disconnect":
      await ConnectionEntity.delete({ connectionId }).go();
      return {
        statusCode: 200,
        body: "Disconnected",
      };
  }
  // その他のルートキーはエラー
  return {
    statusCode: 400,
    body: "Bad Request",
  };
};
```

次はDynamoDB StreamsがトリガーするLambda関数ハンドラー。こっちはちょっと、ひと手間あります。考慮すべき事項のうち、最も重要なものは **「なんらかの理由で正常な通信切断処理が行われず、残存したままのconnectionIdをどうするか問題」** です。切断前に接続先が消えてしまったconnectionIdにメッセージを送信すると、 `GoneException` が発生します。こいつが発生したconnectionIdは削除してやりましょう。

実装するとこんな感じです。

```ts:src/trigger.ts
import { ApiGatewayManagementApi } from "@aws-sdk/client-apigatewaymanagementapi";
import type { DynamoDBBatchResponse, DynamoDBStreamHandler } from "aws-lambda";
import { ConnectionEntity } from "@/entities/connection";
import { unmarshall } from "@aws-sdk/util-dynamodb";
import type { AttributeValue } from "@aws-sdk/client-dynamodb";
import { MessageEntity, type MessageEntityType } from "@/entities/message";

export const handler: DynamoDBStreamHandler = async (event) => {
  // Streamsが流してきたデータのうち、メッセージエンティティのみを抽出
  const messages = event.Records.filter((record) => record.dynamodb?.NewImage)
    .map((record) => {
      const item = unmarshall(
        record.dynamodb!.NewImage! as Record<string, AttributeValue>
      );
      if (item.__edb_e__ === "Message") {
        const { data: message } = MessageEntity.parse({ Item: item });
        return message;
      }
      return null;
    })
    .filter((item): item is MessageEntityType => item !== null);

  // メッセージがなければ終了
  if (messages.length === 0) {
    return {
      batchItemFailures: [],
    };
  }

  // メッセージがあれば、現在WebSocketに接続している全員にメッセージを送信する（今回、ルームはないので）
  const { data: connections } = await ConnectionEntity.query
    .connections({})
    .go({ pages: "all" });
  const api = new ApiGatewayManagementApi({
    endpoint: `${process.env.WS_API_URL}/${process.env.STAGE}/`.replace(
      "wss://",
      "https://"
    ),
  });
  const postCalls = connections.map(async ({ connectionId }) => {
    try {
      await api.getConnection({
        ConnectionId: connectionId,
      });
    } catch (error) {
      const _error = error as Error;
      // 接続先が存在しないなら、接続情報を削除
      if (_error.name === "GoneException") {
        await ConnectionEntity.delete({ connectionId }).go();
        return null;
      }
      console.error(_error);
      return null;
    }
    return await api.postToConnection({
      ConnectionId: connectionId,
      Data: JSON.stringify(messages),
    });
  });
  const result = (await Promise.all(postCalls)).filter((r) => !!r);

  const response: DynamoDBBatchResponse = {
    batchItemFailures: result
      .filter((r) => r?.$metadata.httpStatusCode !== 200)
      .map((r) => ({ itemIdentifier: r?.$metadata.requestId! })),
  };
  return response;
};
```

スケーリングした時のパフォーマンスを考えるなら、少しコードを手直しするべきでしょう。しかし今回は最小構成かつ最短経路でのWebSocket API利用チャットアプリ開発RTA中なので、ひとまずはこれで良いです。Lambda関数ハンドラー２つはこれで実装OK！

最後にメッセージ送信用APIとフロントエンドを実装すれば、チャットを動かせるはず。

## Astroでメッセージ送信用APIとフロントエンドの実装

SST IonのAstroコンポーネントは、SSRモードでAstroをCloudFront + Lambda Functions URLs + S3環境にデプロイする。なのでServer Routeの利用が可能。

今回は速度を優先したいから、メッセージ送信用APIもAstroのServer Route(API Route)を利用するよ。

というわけで、そのメッセージ送信用API on Astro Server Routeはこうなる。

```ts:src/pages/api/message.json.ts
import { MessageEntity, type MessageEntityType } from "@/entities/message";
import type { APIRoute } from "astro";
import { v4 } from "uuid";

export const POST: APIRoute = async (args) => {
  try {
    const json = await args.request.json();
    const messageBody = json.messageBody;
    if (!messageBody) {
      return new Response(
        JSON.stringify({
          error: "messageBody is required",
        }),
        { status: 400 }
      );
    }
    const message: MessageEntityType = {
      messageId: v4(),
      body: messageBody,
      createdAt: Date.now(),
    };
    await MessageEntity.create(message).go();
    return new Response(
      JSON.stringify({
        message,
      }),
      { status: 200 }
    );
  } catch (error) {
    console.log(error);
    return new Response(
      JSON.stringify({
        error: (error as Error).message,
      }),
      { status: 500 }
    );
  }
};
```

あとはフロントエンドの見た目を少し整えて、チャット送信可能なUIを作るだけ。

```vue:src/layouts/BaseLayout.astro
---
interface Props {
  title: string;
}

const { title } = Astro.props;
---

<!doctype html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="description" content="Astro description" />
    <meta name="viewport" content="width=device-width" />
    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
    <meta name="generator" content={Astro.generator} />
    <title>{title}</title>
  </head>
  <body>
    <div class="bg-neutral-950 flex flex-col items-center py-4 min-h-screen">
      <div
        class="w-full prose bg-neutral-50 text-neutral-950 rounded-lg flex-grow"
      >
        <div class="p-4">
          <slot />
        </div>
      </div>
    </div>
  </body>
</html>
```

```vue:src/pages/index.astro
---
import { MessageEntity } from "@/entities/message";
import BaseLayout from "@/layouts/BaseLayout.astro";
const wsApiUrl = `${import.meta.env.WS_API_URL}/${import.meta.env.STAGE}/`;
const { data: messages } = await MessageEntity.query
  .orderByCreatedAt({})
  .go({ pages: "all" });
---

<BaseLayout title="rta-chat">
  <h2>rta-chat</h2>
  <div id="messages" class="mt-4">
    {
      messages.map((message) => (
        <div class="border-b py-2">
          <div class="text-xs text-neutral-400">
            {new Intl.DateTimeFormat("ja-JP", {
              dateStyle: "short",
              timeStyle: "short",
            }).format(new Date(message.createdAt))}
          </div>
          <div>{message.body}</div>
        </div>
      ))
    }
  </div>
  <form
    action="/api/message.json"
    method="POST"
    data-ws-api-url={wsApiUrl}
    class="mt-4"
    id="messageForm"
  >
    <input
      type="text"
      id="messageBody"
      name="messageBody"
      placeholder="メッセージを入力してください"
      class="w-full"
    />
    <div class="flex justify-end mt-2">
      <button type="submit" class="p-2 bg-blue-500 text-white rounded"
        >送信</button
      >
    </div>
  </form>
</BaseLayout>

<script>
  // WebSocketに接続する
  const wsApiUrl = document.getElementById("messageForm")!.dataset.wsApiUrl!;
  const ws = new WebSocket(wsApiUrl);

  // 受信したメッセージを表示する
  ws.onmessage = (event) => {
    const messagesArea = document.getElementById("messages")!;
    const messages = JSON.parse(event.data);
    for (const message of messages) {
      const messageElement = document.createElement("div");
      messageElement.classList.add("border-b", "py-2");
      messageElement.innerHTML = `
      <div class="text-xs text-neutral-400">
        ${new Intl.DateTimeFormat("ja-JP", {
          dateStyle: "short",
          timeStyle: "short",
        }).format(new Date(message.createdAt))}
      </div>
      <div>${message.body}</div>
    `;
      // 末尾に追加
      messagesArea.appendChild(messageElement);
    }
  };

  // 画面遷移前にWebSocketを閉じる
  window.addEventListener("beforeunload", () => {
    ws.close();
  });

  // WebSocketのエラーを表示する
  ws.onerror = (event) => {
    console.error("[onerror] ", event);
  };

  // メッセージ送信
  const messageForm = document.getElementById(
    "messageForm"
  )! as HTMLFormElement;
  messageForm.addEventListener("submit", async (event) => {
    event.preventDefault();
    const messageBodyInput = document.getElementById(
      "messageBody"
    ) as HTMLInputElement;
    const result = await fetch(messageForm.action, {
      method: messageForm.method,
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        messageBody: messageBodyInput.value,
      }),
    });
    if (result.ok) {
      messageBodyInput.value = "";
    } else {
      console.error(result.statusText);
      alert("メッセージの送信に失敗しました");
    }
  });
</script>
```

`bun dev` での動作確認でちゃんとリアルタイム性のあるチャットを実現できていれば、開発モードでの起動を終了して、さっそく本番環境モードでのデプロイを行っちゃおう。

```zsh
sst deploy
```

ステージ名は必要に応じてつけてね。つけなかった場合はデフォルトのステージ名が使われるよ。もし開発モード時と同じステージなら、開発モードを上書きする形で本番モードのSST IonアプリケーションがAWS環境上にデプロイされるからね。

デプロイ完了したら、改めて動作確認。

![スクリーンショット 2024-04-16 0.41.51.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/267156/5659daa4-ebd3-fa76-0eac-da57d647e84a.jpeg)

できた！　これで完成。

## おまけ：リソースの削除

`sst remove` でAWS上にデプロイしたSST Ionアプリケーションを削除できるよ。トラブル防止のためにも、いらなくなったら忘れず消しておこう。

## 筆者プロフィール

Kenpal株式会社でITエンジニアとして色々いじってる faable01 です。

https://www.kenpalinc.com/

ものづくりが好きで、学生時代から創作仲間と小説を書いたりして楽しんでいたのですが、当時はその後自分がIT技術者になるとはつゆ程も思っていませんでした。紆余曲折あり、20代の半ばを過ぎた頃に初めてこの業界と出会った形です。

趣味は「技術記事を口語で書くこと」です。

業務日報SaaS **「RevisNote」** を運営しています。RevisNoteでもSSTを用いて、迅速なサーバレス開発を行っています。中でもSSTのLive Lambda機能はやはり素晴らしいです。

RevisNoteは、リッチテキストでの日報と、短文SNS感のある分報を書けるのが特徴で、組織に所属する人数での従量課金制です。アカウント開設後すぐ使えて、無料プランもあるから、気軽にお試しください。

https://revisnote.com?202403150825