※更新履歴は記事の最後にあります。
※2023/12/31 毎日更新してたらバージョン管理が無理ゲー化しつつあるので、v0.1.017以降は Githubの方だけでやろうと思います、
ここ数日、Bun の Expres的フレームワークである Elysia を使って chat を作ってきました。
毎日少しづつ Update してきましたが、CSS と Cookie による操作などを追加して見た目やソースも結構変わったので、新しいページを書こうと思います。
今回も更新しながら進化させていこうと行く企画になります。更新履歴は今回も記事の最後に付けていきます。
まず、今回のバージョンと以前のバージョンのスクショを比較しておきましょう。今回のCSSとCookie追記で何をしたのかがざっくりわかるかなと思います。
今回の動作サンプル
まぁ、今回も作ったものを pm2 start "bun dev" してあげておきます。まぁ期間限定になるとは思いますが、参考までにどうぞ。ブラウザで複数開いて書き込むと動作が判ります。
以前の記事
今回 v0.1.013 のスクショ
以前のバージョンのスクショ
これがもとのチャットです。CSS無しです。かなり印象が違いますよね。
何をしたのか?
ざっとこんな機能を足しました。
※更新履歴が増えてきたのでそろそろ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以上が当たり前の時代になってきましたが、この簡易チャットには充分でしょう。
メッセージボックスは、文字量が増えても自動で大きくなるようにはしてますが、データベースのテーブル作成時に msg VARCHAR(255) としてあるので、半角で225文字、全角だと最大で約127文字までというとこかな。まぁ好きな量に調整すれば良いかなと思います。
(9) SQLite の VARCHAR の最大サイズはどれくらいですか?SQLite は、VARCHAR の長さを強制しません。VARCHAR(10) を宣言すると、SQLite はそこに 5 億の文字列を喜んで格納します。そして、5 億文字すべてがそのまま保持されます。コンテンツが切り捨てられることはありません。SQLite は、 Nの値に関係なく、 "VARCHAR( N )"の列型を"TEXT" と同じであると認識します。
このままでは何万文字でも入ってしまします(^^;
そこで、これを追記しときました。まぁバリデーションでやればよいのだろうけど、ElysiaとBunのバリデーション作法を後で調べて書き直そう。
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 よりは、各種報告通りだいたい速い印象です。
詳細はそのうちしようと思います。また、例によってしばらく更新も続けたいと思っています。
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]+' > ('+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";と読込み、サーバー側でもクライアント側でも同じ関数を利用しています。
//===========================================
// タイムゾーンを変更する 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:
- 定数名の修正など
- githubへ公開しました https://github.com/toshirot/mychat
- 2023/12/30 v0.1.016:
- メッセージの改行を有効にした
- 2023/12/28 v0.1.015:
- static プラグインを適用した
- cssを/public/css配下へ分割した
- 2023/12/25 v0.1.014/ v0.1.013/ v0.1.012:
- テーブルのname、msgカラムへの文字数制限
- 入力ボックスにもCSSを適用
- 名前修正時のcookie登録
- 2023/12/25 v0.1.011:
- 会話を左右に分ける
- uidをcookieに追加して自分を右側にする
- 会話をCSSで色分けする
- 2023/12/24 v0.1.005:
- 名前欄をcookie保存する
- 送信したらメッセージ欄をクリアする
- 2023/12/23 v0.1.004:
- メッセージ欄の改行送信機能を追加
- 2023/12/23 v0.1.003:
- rc/utiles.ts にタイムゾーンの変更関数 adjustHours追加
- 2023/12/22 v0.1.002:
- typescript 的な型指定や interface定義追加
- 2023/12/21 v0.1.001:
- interface MsgData を追加
- typescript 的な型指定を追加
- chatName と viersion番号を追加
$ bun add @elysiajs/static
├─ public/
│ └─ css/
│ ├─ base.css
│ ├─ input-box.css
│ └─ msg-box.css
$ bun add @types/bun
$ bun add @elysiajs/html
最近 Qiita に書いた Bun 関連の記事10選