【Elysia/Bun】先日作った、Elysia のチャットの新バージョン。CSSとCookieを足してみた。

※[更新履歴は記事の最後にあります。](https://qiita.com/toshirot/items/c5654156c8799ac28d83#%E6%9B%B4%E6%96%B0%E5%B1%A5%E6%AD%B4)
※2023/12/31 毎日更新してたらバージョン管理が無理ゲー化しつつあるので、v0.1.017以降は Githubの方だけでやろうと思います、

https://github.com/toshirot/mychat

----

ここ数日、Bun の Expres的フレームワークである Elysia を使って[ chat ](https://qiita.com/toshirot/items/c5654156c8799ac28d83)を作ってきました。

毎日少しづつ Update してきましたが、CSS と Cookie による操作などを追加して見た目やソースも結構変わったので、新しいページを書こうと思います。

今回も更新しながら進化させていこうと行く企画になります。更新履歴は今回も記事の最後に付けていきます。

まず、今回のバージョンと以前のバージョンのスクショを比較しておきましょう。今回のCSSとCookie追記で何をしたのかがざっくりわかるかなと思います。

# 今回の動作サンプル

まぁ、今回も作ったものを pm2 start "bun dev" してあげておきます。まぁ期間限定になるとは思いますが、参考までにどうぞ。ブラウザで複数開いて書き込むと動作が判ります。

http://74.226.208.203:9013/

# 以前の記事

https://qiita.com/toshirot/items/c5654156c8799ac28d83

# 今回 v0.1.013 のスクショ
 v0.1.013 のスクショ
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7103/862b74cc-fc8f-5a99-4c24-bb8e54955e46.png)
 v0.1.011 のスクショ
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7103/e23ed9e6-090c-4966-012f-ad74255104e6.png)

# 以前のバージョンのスクショ

これがもとのチャットです。CSS無しです。かなり印象が違いますよね。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7103/90afb399-e326-ce0a-a3c2-3841336dae0e.png)

2つ並べてあります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7103/86407902-d2da-6fd9-5aaf-1f26a94ede6e.png)

# 何をしたのか？

ざっとこんな機能を足しました。
※更新履歴が増えてきたのでそろそろGithubにでも上げてバージョン管理すべきだなぁ

v0.1.015 2023/12/28 v0.1.015:
・static プラグインを適用した
・cssを/public/css配下へ分割した
v0.1.014/ v0.1.013/ v0.1.012
・テーブルのname、msgカラムへの文字数制限
・入力ボックスにもCSSを適用
・名前修正時のcookie登録
v0.1.011 
・会話を左右に分ける
・uidをcookieに追加して自分を右側にする
・会話をCSSで色分けする

「会話を左右に分けて、自分を右側にする」ためには、過去の発言のどれが自分の発言かわかる必要があります。

そのために、ユーザーIDを使うことにして、今回は最初の接続時に、cookie に uid というキーが無ければ、ランダムに生成した一意の値を作り、自分の cookie へ記録することにしました。

その uid を、下記のように名前、メッセージと共に送信して、届いた uid が自分のものなら右側へ出力するという仕組みです。

一意の値を作るのに今回は md5とrandom を使っています。まぁ最近は、sha3以上が当たり前の時代になってきましたが、この簡易チャットには充分でしょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7103/fe7b9eb9-b80b-eed1-d162-243c1be8100d.png)
<strike>メッセージボックスは、文字量が増えても自動で大きくなるようにはしてますが、データベースのテーブル作成時に msg VARCHAR(255) としてあるので、半角で225文字、全角だと最大で約127文字までというとこかな。まぁ好きな量に調整すれば良いかなと思います。</strike>

ここまで書いて大変なことに気付きました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7103/bb805bb6-bcde-22e7-c1f1-29e03650e2d4.png)


<blockquote>
(9) SQLite の VARCHAR の最大サイズはどれくらいですか?

SQLite は、VARCHAR の長さを強制しません。VARCHAR(10) を宣言すると、SQLite はそこに 5 億の文字列を喜んで格納します。そして、5 億文字すべてがそのまま保持されます。コンテンツが切り捨てられることはありません。SQLite は、 Nの値に関係なく、 "VARCHAR( N )"の列型を"TEXT" と同じであると認識します。
</blockquote>

https://www.sqlite.org/faq.html#q9

このままでは何万文字でも入ってしまします(^^;

そこで、これを追記しときました。まぁバリデーションでやればよいのだろうけど、ElysiaとBunのバリデーション作法を後で調べて書き直そう。

```js:src/index.tsに追記
            if(msgoj.head.type==='msg'){
                console.log('msg',msgoj.body)
                msgoj.body.name = msgoj.body.name.slice(0, 20);//文字数制限
                msgoj.body.msg = msgoj.body.msg.slice(0, 300);//文字数制限
```



# 今回の環境
クラウド： Azure VM (これは何でも良い)
OS：　Ubuntu 20.04.6 LTS (GNU/Linux 5.15.0-1050-azure x86_64)
Bun：　v1.0.18
Elysia： v0.7.30
SQLite3: v3.31.1(Elysia を入れると自動で入る)

BunのインストールやElysia プロジェクト作成は、本家 などを参考にしてみてください。

# 今回はこんなディレクトリが作られます
SQLite 用の ディレクトリを ./db としました。同時書き込みが行われる状況でパフォーマンスを向上させる先行書き込みログ モード(WAL) を使っているので、DBファイルが3個になっています。

```
.ccchart/
├─ db/
│    ├─ mychat.sqlite     // 通常の SQLiteファイル
│    ├─ mychat.sqlite-shm // ジャーナルモード用ファイル
│    └─ mychat.sqlite-wal // ジャーナルモード用ファイル
├─ src/
│    ├─ index.ts    // bun dev で起動するファイル
│    └─ utiles.ts   // タイムゾーンの変更関数を置いた
├─ public/          // v0.1.015 で追加したstatic ディレクトリ
│    └─ css/ 
│        ├─ base.css 
│        ├─ input-box.css
│        └─ msg-box.css
├─ README.md
├─ bun.lockb
├─ node_modules/
├─ package.json
└─ tsconfig.json
```


# 起動ファイルの中身

起動ファイルは src/index.ts です。
今回も、index.ts に全部入りで書きました。もちろん、実際に使う時は、適宜デイレクトリに小分けして使うようですし、、css は 例えば、プラグインもあるので Tailwind CSS などを使いたかったのですが、今回は一式まとめて書いてましたが v1.0.015でstaticプラグインをaddしてスタイルファイルを静的デイレクトリへ分割しました。

参考: <b>Elysia >Elysia Tailwind</b>

https://github.com/gtramontina/elysia-tailwind

因みに今回は通増の crypt を使ってますが、何故か「import文」でうまくいかなかったので 「require」で読み込みました。まぁ読み込めなかった原因がパッケージ側なのかは不明ですが、BUn や Elysia は、import も require も普通に混在できるのが新鮮ではあります。

因みにここまで触ってきた経験では、何故かimport文の方が速い印象がありました。もちろん、パッケージによるとは思いますし、どちらも Node.js よりは、各種報告通りだいたい速い印象です。

詳細はそのうちしようと思います。また、例によってしばらく更新も続けたいと思っています。

```ts: src/index.tsの中身
import { Elysia } from 'elysia';
import type { WS } from '@types/bun';
import { html } from '@elysiajs/html'
import { staticPlugin } from '@elysiajs/static'
import { Database } from 'bun:sqlite';
import { adjustHours } from "./utils";
const crypto = require('crypto');

//===========================================
// 定数

// チャット名
const CHAT_NAME = 'myChat';
// バージョン
const VERSION = '0.1.017';
// 出力するメッセ―ジ数
const LIMIT = 20;
// ホスト HTTP と WebSocket 共通
const HOST = '74.226.208.203';
// ポート HTTP と WebSocket 共通
const PORT = 9012;

//===========================================
// interface

type MsgType = 'msg' | 'info' | 'bc'; 

interface Head {
    type: MsgType;
}
interface Body {
    type: any;
}
interface MsgData {
    head: Head;
    body: Body;
}

//===========================================
// データベースの作成

// データベースファイルの名前を指定
const DB_FILE_NAME = './db/'+CHAT_NAME+'.sqlite';
// テーブルの名前を指定
const TABLE_NAME = 'chat_logs';
// 新しいデータベースインスタンスを作成し、ファイルが存在しない場合はデータベースファイルを作成
const db = new Database(DB_FILE_NAME, { create: true });
// 同時書き込みが行われる状況でパフォーマンスを大幅に向上させる先行書き込みログ モード(WAL) 
db.exec('PRAGMA journal_mode = WAL;');

// テーブルが存在しない場合はテーブルを作成
const sql_table_create =
    `CREATE TABLE IF NOT EXISTS 
        ${TABLE_NAME}
        (
            id INTEGER PRIMARY KEY, 
            name VARCHAR(255), 
            msg VARCHAR(255), 
            uid VARCHAR(32), 
            created_at TIMESTAMP
        );`
        
// SQLを実行する　
doQuery(db, sql_table_create);

//===========================================
// サーバーを立てる HTTPとWebSocket
// 

// クライアントを入れとく配列 ブロードキャスト用
let clients: WebSocket[] = [];

const app = new Elysia()
    .use(html())
    .use(staticPlugin()) //ここでstaticプラグインを適用する
    .get('/', ({ cookie: { name, uid } }) => { 
       
        // cookie から名前とuidを取得
        if(!name.value){
            // name をセットする
            name.value=encodeURIComponent('通りすがりさん1')
        } else {
            //name.value=decodeURIComponent(name.value)
        }
        if(!uid.value){
            // uid をセットする
            uid.value= mkmd5(Math.random().toString())
        }
        //name.value=decodeURIComponent(name.value)
    return  `
    <html lang='ja'>12345
        
        <head>
            <meta charset="utf-8">
            <meta name=”viewport” content=”width=device-width,initial-scale=1″>
            <title>${CHAT_NAME} updating</title>
            <script>${adjustHours} </script>
            <link rel="stylesheet" href="/public/css/base.css">
            <link rel="stylesheet" href="/public/css/input-box.css">
            <link rel="stylesheet" href="/public/css/msg-box.css">

        </head>
        <body>
 
            <a href="https://qiita.com/toshirot/items/c5654156c8799ac28d83" target=qiita>
            →このチャットの作り方</a><br><br>
            <form id="contact">
                <div class="input_box">
                <div class="head">
                    <h2>${CHAT_NAME} v${VERSION}</h2>
                </div>
                名前：<br />
                <input type="text" id="input_name"  value="${name.value}" uid="${uid.value}" placeholder="Name" /><br />
                メッセージ：<br />
                <textarea type="text" id="input_msg" placeholder="メッセージを入力してください"></textarea><br />
                <div class="message">送信</div>
                <button id="btn_send" type="submit">
                   送信
                </button>
                </div>
            </form>

            <ul safe id=msgs></ul>

            <script>
                // 接続
                const socket = new WebSocket('ws://${HOST}:${PORT}/ws');
                // 接続時イベント
                socket.onopen = function (event) {
                    console.log('cookie at onopen: ', document.cookie)
                    socket.send(JSON.stringify({
                        head:{type: 'info'},
                        body:{
                            name: 'system',
                            msg: '誰かがサーバーへ接続しました。',
                            uid: 'system'
                        }
                    }))
                };
                // 着信時イベント
                socket.onmessage = function (event) {

                    // event.data is 
                    //  e.g. '{"head":{"type":"msg"},"body":[[52,"aa","aa","2023-12-20 14:39:04"]]}'
                    if(!event.data)return // 無ければ無視する
                    let data=JSON.parse(event.data) // object化する
                    if(!data.body)return // 無ければ無視する

                    // 下から上に向かって追記するので SELECT ASC で昇順取得したリストをafterbeginで追記する
                    data.body.reverse()
                    let msg_class='msgbox-left'
                    let msgbox=''
                    for(let i=0;i<${LIMIT};i++){
                        try{
                            if(!data.body[i][3])continue
                            data.body[i][2]=data.body[i][2].replace(/\\n/g, '<br>')
                            // メッセージを出力する
                            if(!!data.body[i]){
                                if(data.body[i][3]===document.cookie.match(/uid=(.{0,32})/)[1]){
                                    // 自分のメッセージはright側に表示する
                                    msg_class='msgbox-right'
                                } else {
                                    msg_class='msgbox-left'
                                    if(data.head.type==='info'){
                                        msg_class='msgbox-info'
                                    }
                                }
                                // msgbox を作る
                                msgbox='<div class="msgbox '+msg_class+'" style="">\
                                    <div class="namebox">\
                                    '+data.body[i][1]+' &gt; ('+adjustHours(data.body[i][4], +9)+') \
                                    </div>\
                                    <div class="msg" uid="'+data.body[i][3]+'" \
                                    style="display:block;"> \
                                    '+data.body[i][2]+'\
                                    </div>\
                                    <div style="clear:both;inline-block;">\
                                    </div>\
                                </div>'
                                msgs.insertAdjacentHTML('afterbegin', msgbox)
                            } 
                        } catch(e){}
                    }
                };
                // DOM構築時イベント
                document.addEventListener('DOMContentLoaded', function () {
                    // 名前をcookieから取得し表示する
                    input_name='name='+decodeURIComponent(
                        document.cookie.match(/(?:^|;)\s*name=([^;]+)/)[1]
                    )+';';
                });
                // 名前入力時イベント
                input_name.addEventListener('keyup', function () {
                    // 名前をcookieに保存する
                    document.cookie='name='+encodeURIComponent(input_name.value)+';';
                });
                // 送信ボタンクリック時イベント
                btn_send.addEventListener('click', function () {
                    // 名前とメッセージがあれば送信する
                    if(!!input_name && !!input_msg.value) {
                        // 名前をcookieに保存する
                        document.cookie='name='+encodeURIComponent(input_name.value)+';';
                        document.cookie='uid=${uid.value};';
                        // 送信する
                        socket.send(JSON.stringify({
                            head:{type: 'msg'},
                            body:{
                                name: input_name.value,
                                msg: input_msg.value,
                                uid: '${uid.value}'
                            }
                        }));
                        // 送信したらメッセージ欄を空にする
                        input_msg.value='';
                    }
                });
            </script>
        </body>
    </html>
        `})

    // WebSocket サーバー
    .ws('/ws', {
        open(ws: WS): void {

            // クライアント配列を作る (ブロードキャスト等で利用する)。
            clients.push(ws)
            // DBからメッセージを 初期 LIMIT件 降順で取り出してクライアントへ送信する
            let sql_select = 'SELECT * FROM ' + TABLE_NAME + ' ORDER BY ID DESC LIMIT '+ LIMIT +';'
            let res: any= doQuery(db, sql_select);
            // データ配列を接続してきたクライアントへ返す
            const data: MsgData = {
                head: {
                  type: "msg",
                },
                body: res,
            };
            // メッセージを送信する
            ws.send(JSON.stringify(data));
            
        },
        // メッセージが届いたら返すだけの簡単なエコーサーバー
        message(ws: WS, msgoj: MsgData): void {
            // メッセージの構文エラーははじく
            if (!msgoj || !msgoj.head || !msgoj.body) return;

            // Elysia の XSS やバリデーションの確認をまだしていないので、
            // 一応、msgoj を文字列化してスクリプトタグを除去し、オブジェクトへ戻す
            if(typeof msgoj=='object'){
                let msgstr=JSON.stringify(msgoj)
                msgstr=(''+msgstr).replace(/<script\b[^>]*>([\s\S]*?)<\/script>/gi, '');
                msgoj=JSON.parse(msgstr)
            } else return; // オブジェクト以外は無視する
  
            // タイプにより処理を分ける
            // type:msg はDBへ登録する
            if(msgoj.head.type==='msg'){
                console.log('msg',msgoj.body)
                msgoj.body.name = msgoj.body.name.slice(0, 20);
                msgoj.body.msg = msgoj.body.msg.slice(0, 300);
                let sql_ins = 
                        'INSERT OR IGNORE INTO ' 
                        + TABLE_NAME 
                        + ' VALUES (null, "'+msgoj.body.name+'", "'+msgoj.body.msg+'", "'+msgoj.body.uid+'", CURRENT_TIMESTAMP);'
                // insert する
                doQuery(db, sql_ins)
                // 最後の 1件だけ select する
                let sql_1 = 'SELECT * FROM ' + TABLE_NAME + ' ORDER BY ID DESC LIMIT 1;'
                let res = doQuery(db, sql_1)//こんな配列で返ってくる。 [[0件目], [1件目], [2件目], [3件目]] 
                // sql_1 結果セットのメッセージ配列をブロードキャストする
                broadCast(ws, msgoj.head.type, res)
            } else if(msgoj.head.type==='info'){
                // infoは DB へ保存しない。
                // 届いたinfoメッセージをブロードキャストする
                let time=new Date()
                broadCast(ws, msgoj.head.type, [['', '--※info', msgoj.body.msg, msgoj.body.uid, 
                  // UTCから日本時間への変換をクライアント側でしてるので、
                  // クライアントから届いた日本時間をマイナス9時間して 
                  // SQLite出力同様のUTCに揃える
                  adjustHours(new Date(), -9)
                ]]) 
            } else {}
        }
    })
    .listen(PORT, (token: any) => {
        if (token) {
            console.log(`Listening to port ${PORT}`);
        } else {
            console.error(`Failed to listen to port ${PORT}`);
        }
    });

//===========================================
// uid を作成する関数
//  @param {String} sql - 実行するSQLクエリ
//  @returns {String} - 結果の文字列
function mkmd5(str: string): string {
    return crypto.createHash('md5').update(str).digest('hex')
}

//===========================================
// データベースクエリを実行する関数
//  @param {String} sql - 実行するSQLクエリ
//  @returns {Array} - 結果の配列
type QueryResultType = Array<any>; 
function doQuery(db: Database, sql: string): QueryResultType {
    return db.query(sql).values();
}

//===========================================
// 全クライアントへブロードキャストする関数
//  @param {Object} ws - WebSocket インスタンス
//  @param {String} msg - 送信するメッセージ
//  @returns {void} - なし

// 全クライアントへブロードキャストする
function broadCast(ws: WS, type: any, msg: any): void {
    let msgtype=(type==='info')?type:'bc'
    clients.forEach(function (socket, i) {
        const data: MsgData = {
            "head": {
              "type": msgtype
            },
            "body": msg,
        };
        // console.log(data)
        socket.send(JSON.stringify(data) );
    })
}
```

前回も書きましたが、SQLite のタイムゾーンがUTCなので、タイムゾーンを変更する関数を src/utiles.ts に置きました。

一応、SQLite DBに保存する日付とサーバー側は UTC のままにして、表示側で日本時間に変換しています。

因みに、src/index.ts で import { adjustHours } from "./utils";と読込み、サーバー側でもクライアント側でも同じ関数を利用しています。

```ts: src/utiles.tsの中身
//===========================================
// タイムゾーンを変更する e.g. UTC -> JST 
//  @param {Object} date - Date インスタンス
//  @param {Number} adjustHour - 調整する時間数 e.g. -9 | +9
//  @returns {string} - 時間の文字列 e.g. '2023-12-22 09:59'
export function adjustHours(date: Date, adjustHour: number): string {
    if(date instanceof Date === false)date=new Date(date)
    date.setHours(date.getHours() + adjustHour)
    return date.toLocaleString(
        ["ja-JP"], {
        year: "numeric",
        month: "2-digit",
        day: "2-digit",
        hour: "2-digit",
        minute: "2-digit",
      })
}
```


# 更新履歴

<div style=font-size:12px;margin-left:30px;clear:both;>
    <h3>Update</h3>
    <ul>
        <li>2023/12/31 v0.1.016: 
            <ol>
                <li>定数名の修正など</li>
                <li>githubへ公開しました https://github.com/toshirot/mychat </li>
            </ol>
        </li>
        <li>2023/12/30 v0.1.016: 
            <ol>
                <li>メッセージの改行を有効にした</li>
            </ol>
        </li>
        <li>2023/12/28 v0.1.015: 
            <ol>
                <li>static プラグインを適用した</li>
                <li>cssを/public/css配下へ分割した</li>
            </ol>
        </li>
        <li>2023/12/25 v0.1.014/ v0.1.013/ v0.1.012: 
            <ol>
                <li>テーブルのname、msgカラムへの文字数制限</li>
                <li>入力ボックスにもCSSを適用</li>
                <li>名前修正時のcookie登録</li>
            </ol>
        </li>
        <li>2023/12/25 v0.1.011: 
            <ol>
                <li>会話を左右に分ける</li>
                <li>uidをcookieに追加して自分を右側にする</li>
                <li>会話をCSSで色分けする</li>
            </ol>
        </li>
        <li>2023/12/24 v0.1.005: 
            <ol>
                <li>名前欄をcookie保存する</li>
                <li>送信したらメッセージ欄をクリアする</li>
            </ol>
        </li>
        <li>2023/12/23 v0.1.004: 
            <ol>
                <li>メッセージ欄の改行送信機能を追加</li>
            </ol>
        </li>
        <li>2023/12/23 v0.1.003: 
            <ol>
                <li>rc/utiles.ts にタイムゾーンの変更関数 adjustHours追加</li>
            </ol>
        </li>
        <li>2023/12/22 v0.1.002: 
            <ol>
                <li>typescript 的な型指定や interface定義追加</li>
            </ol>
        </li>
        <li>2023/12/21 v0.1.001: 
            <ol>
                <li>interface MsgData を追加</li>
                <li>typescript 的な型指定を追加</li>
                <li>chatName と viersion番号を追加</li>
            </ol>
        </li>
    </ul>
</div>

```sh:v0.1.015 で追加したstaticプラグイン
$ bun add @elysiajs/static
```

```:v0.1.015 で追加したstatic ディレクトリ
├─ public/        
│    └─ css/ 
│        ├─ base.css 
│        ├─ input-box.css
│        └─ msg-box.css
```
```sh:v0.1.002で追加した
$ bun add @types/bun
```
```:v0.1.001で追加した
$ bun add @elysiajs/html
```


# 最近 Qiita に書いた Bun 関連の記事10選

https://qiita.com/toshirot/items/c5654156c8799ac28d83

https://qiita.com/toshirot/items/3f147350f51942a29bf3

https://qiita.com/toshirot/items/c0ecf87f86bf50a2912b

https://qiita.com/toshirot/items/01c7f779d87985b1f9be

https://qiita.com/toshirot/items/2f0db79b3c2480e64f41

https://qiita.com/toshirot/items/94616478d3e73e7cc5b9

https://qiita.com/toshirot/items/f6bd561bc2522f1d26e0

https://qiita.com/toshirot/items/a5dc53ef191d20bbeb42

https://qiita.com/toshirot/items/74476af27e67d8534c5a

https://qiita.com/toshirot/items/94961b6dde72be80ab0c

https://qiita.com/toshirot/items/7a0555b46b3a64a4f643