LoginSignup
2
0

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

Last updated at Posted at 2023-12-26

更新履歴は記事の最後にあります。
※2023/12/31 毎日更新してたらバージョン管理が無理ゲー化しつつあるので、v0.1.017以降は Githubの方だけでやろうと思います、


ここ数日、Bun の Expres的フレームワークである Elysia を使って chat を作ってきました。

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

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

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

今回の動作サンプル

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

以前の記事

今回 v0.1.013 のスクショ

v0.1.013 のスクショ
image.png
v0.1.011 のスクショ
image.png

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

これがもとのチャットです。CSS無しです。かなり印象が違いますよね。
image.png

2つ並べてあります。
image.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
メッセージボックスは、文字量が増えても自動で大きくなるようにはしてますが、データベースのテーブル作成時に msg VARCHAR(255) としてあるので、半角で225文字、全角だと最大で約127文字までというとこかな。まぁ好きな量に調整すれば良いかなと思います。

ここまで書いて大変なことに気付きました。
image.png

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

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

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

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

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してスタイルファイルを静的デイレクトリへ分割しました。

参考: Elysia >Elysia Tailwind

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

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

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

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";と読込み、サーバー側でもクライアント側でも同じ関数を利用しています。

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",
      })
}

更新履歴

Update

  • 2023/12/31 v0.1.016:
    1. 定数名の修正など
    2. githubへ公開しました https://github.com/toshirot/mychat
  • 2023/12/30 v0.1.016:
    1. メッセージの改行を有効にした
  • 2023/12/28 v0.1.015:
    1. static プラグインを適用した
    2. cssを/public/css配下へ分割した
  • 2023/12/25 v0.1.014/ v0.1.013/ v0.1.012:
    1. テーブルのname、msgカラムへの文字数制限
    2. 入力ボックスにもCSSを適用
    3. 名前修正時のcookie登録
  • 2023/12/25 v0.1.011:
    1. 会話を左右に分ける
    2. uidをcookieに追加して自分を右側にする
    3. 会話をCSSで色分けする
  • 2023/12/24 v0.1.005:
    1. 名前欄をcookie保存する
    2. 送信したらメッセージ欄をクリアする
  • 2023/12/23 v0.1.004:
    1. メッセージ欄の改行送信機能を追加
  • 2023/12/23 v0.1.003:
    1. rc/utiles.ts にタイムゾーンの変更関数 adjustHours追加
  • 2023/12/22 v0.1.002:
    1. typescript 的な型指定や interface定義追加
  • 2023/12/21 v0.1.001:
    1. interface MsgData を追加
    2. typescript 的な型指定を追加
    3. chatName と viersion番号を追加
v0.1.015 で追加したstaticプラグイン
$ bun add @elysiajs/static
v0.1.015 で追加したstatic ディレクトリ
├─ public/        
│    └─ css/ 
│        ├─ base.css 
│        ├─ input-box.css
│        └─ msg-box.css
v0.1.002で追加した
$ bun add @types/bun
v0.1.001で追加した
$ bun add @elysiajs/html

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

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0