WebSocket ミニマム実装 (TypeScript x Serverless x AWS API Gateway)

- なんか古いやり方の記事しかなかったので、自分で調べたことを書き残しておくよ

## 前提・読者の想定
- 普段 Serverless x Lambda x Node.js x TypeScript が扱えていること
    - そこの詳しい説明は無い

## やりたいこと
- WebSocket サーバーを TypeScript で書くよ
- AWS API Gateway で serve するよ
    - Serverless でデプロイするよ
- ローカル環境でも簡単にデバッグしたいよ
- 以上のことをシンプルに実現したいよ
    - 公式サンプルだとDynamo使ったりしてるけど、Dynamo触りたくないし。。。

## メリット
- AWS でオートスケールする WebSocket サーバーが書けるよ
- とってもお手軽だし、そのうえランニングコストも低い
    - (比較対象: GCP CloudRun しか見てないけど。)

## やったこと

### 準備

```sh
# in your lambda project
npm i aws-lambda-ws-server

# for testing websocket
npm i -g wscat
```

他のドキュメントでよく見る `serverless-websockets-plugin` は **本日時点で不要**

#### [aws-lambda-ws-server](https://github.com/JamesKyburz/aws-lambda-ws-server)
- メリット
    - ローカル起動と、Serverless でのデプロイがサポートされているよ
    - ちゃんと動いたし、中身がシンプルでバグる余地も少なそうだから採用したよ
- デメリット
    - Starが少ないよ
    - README がいろいろ説明不足だよ
    - 型定義もないよ 😇
- **注意点**
    - MAPPING_KEY 環境変数で route を分岐するために使うプロパティ名の指定が必要
    - ローカル起動のデフォルトでは `(message json).message` が参照される (なんでやねん)
    - デプロイ後のデフォルトは `(message json).action`
    - このサンプルでは環境変数を使ってローカルの挙動を `action` に合わせている

### src/handler.ts

```ts
const ws = require("aws-lambda-ws-server");

export const websocketApp = ws(
  ws.handler({
    // 接続時に通る
    async connect(event: WebSocketEvent) {
      console.log("connection %s", event.id);
      return { statusCode: 200 };
    },

    // 切断時に通る
    async disconnect(event: WebSocketEvent) {
      console.log("disconnect %s", event.id);
      return { statusCode: 200 };
    },

    // 未定義の action を指定すると通る
    async default(event: WebSocketEvent) {
      const {
        id: connectionId,
        message: { body },
        context: { postToConnection },
      } = event;

      console.log("default message", connectionId, body);

      await postToConnection({ action: "default", echo: body }, connectionId);

      return { statusCode: 200 };
    },

    // "myAction1" アクションのハンドラ
    async myAction1(event: WebSocketEvent) {
      const {
        id: connectionId,
        message: { body },
        context: { postToConnection },
      } = event;

      console.log("myAction1", connectionId, body);

      await postToConnection({ action: "myAction1", echo: body }, connectionId);

      return { statusCode: 200 };
    },

    // "myAction2" アクションのハンドラ
    async myAction2(event: WebSocketEvent) {
      // 省略: myAction1 と同等の実装
    },

    // 動かないサンプル (ローカルでは動くが、デプロイ後は動かない)
    // なぜなら serverless.yml の route 定義が漏れているから
    async myAction3(event: WebSocketEvent) {
      // 省略: myAction1 と同等の実装
    },
  })
);


/**
 * aws-lambda-ws-server に型定義がないので書いておく
 */
export type WebSocketEvent = {
  id: string;
  event: {
    requestContext: {
      routeKey: string;
      eventType: "CONNECT" | "DISCONNECT" | "MESSAGE";
      connectionId: string;
    };
    multiValueHeaders: { [key: string]: string[] };
    body: string;
  };
  context: {
    postToConnection: (body: any, connectionId: string) => Promise<void>;
  };
  // message は any json だが、このプロジェクトでは常にこの形として定義する
  message: {
    action: string;
    body: any;
  };
};

```

### serverless.yml

※追記したところだけ抜粋

```yml

provider:
  iam:
    role:
      statements:
        - Effect: Allow
          Action:
            - "execute-api:ManageConnections"
          Resource:
            - "arn:aws:execute-api:*:*:**/@connections/*"

functions:
  websocketApp:
    handler: src/handler.websocketApp
    events:
      - websocket:
          route: $connect
      - websocket:
          route: $disconnect
      - websocket:
          route: $default
      - websocket:
          route: myAction1
      - websocket:
          route: myAction2
```

### ローカルデバッグ

console 1 (server)

```sh
# 先述の注意点。route分岐のために使うプロパティ名を環境変数で指定
$ export MAPPING_KEY=action
$ npx ts-node-dev --inspect=9229 --clear --respawn src/handler.ts

# 以下 client からのリクエストに対するログ出力
connection NB+jzmXmqOtPpnCa8eNVDw==
myAction1 NB+jzmXmqOtPpnCa8eNVDw== { hello: 'websocket1' }
myAction2 NB+jzmXmqOtPpnCa8eNVDw== { hello: 'websocket2' }
myAction3 NB+jzmXmqOtPpnCa8eNVDw== { hello: 'websocket3' }
default message NB+jzmXmqOtPpnCa8eNVDw== { hello: 'websocket4' }
disconnect NB+jzmXmqOtPpnCa8eNVDw==
```

console 2 (client)

```sh
$ wscat -c ws://localhost:5000
Connected (press CTRL+C to quit)
> {"action":"myAction1","body":{"hello":"websocket1"}}
< {"action":"myAction1","echo":{"hello":"websocket1"}}

> {"action":"myAction2","body":{"hello":"websocket2"}}
< {"action":"myAction2","echo":{"hello":"websocket2"}}

> {"action":"myAction3","body":{"hello":"websocket3"}}
< {"action":"myAction3","echo":{"hello":"websocket3"}}

> {"action":"myAction4","body":{"hello":"websocket4"}}
< {"action":"default","echo":{"hello":"websocket4"}}
```

### デプロイ後の挙動

```sh
& wscat -c wss://{****}.execute-api.ap-northeast-1.amazonaws.com/{****}
Connected (press CTRL+C to quit)
> {"action":"myAction1","body":{"hello":"websocket1"}}
< {"action":"myAction1","echo":{"hello":"websocket1"}}

> {"action":"myAction2","body":{"hello":"websocket2"}}
< {"action":"myAction2","echo":{"hello":"websocket2"}}

# ⭐ serverless.yml に "myAction3" route 定義がないため action が認識されていない
> {"action":"myAction3","body":{"hello":"websocket3"}}
< {"action":"default","echo":{"hello":"websocket3"}}

> {"action":"myAction4","body":{"hello":"websocket4"}}
< {"action":"default","echo":{"hello":"websocket4"}}
```