Denoの標準ライブラリでチャットアプリを作ってみました🦕

Denoの標準ライブラリでチャットアプリを作ってみました🦕

Denoとは、Node.jsの開発者であるRyan Dahlさんによって作られたJavaScript/TypeScriptの新しいランタイム(JavaScript/TypeScriptを実行できる環境)です。v1.0が2020/5/13にリリースされました。Denoは、Ryan DahlさんがNode.jsの設計を見直して改めて開発したランタイムであり、「そのうちNode.jsがDenoにリプレイスされるんじゃないか!?」ということで注目を集めています。

本記事では、Denoの標準ライブラリを使って簡単なチャットアプリを作りながら、Node.jsと比較したDenoの特長を紹介していきます。

Denoのインストール

まずはHomebrewでDenoをインストールします

$ brew install deno

インストールが完了したら、バージョンを確認してみます

$ deno --version
deno 1.0.3
v8 8.4.300
typescript 3.9.2

Deno、V8、TypeScriptのバージョンが表示されます。Denoは標準でTypeScriptをサポートしています。

DenoでJavaScript/TypeScriptを実行する際は、

$ deno run index.ts

上記のようにdeno run (ファイル名)します。また、次のようにURLでファイルを指定して実行することもできます。

$ deno run https://deno.land/std/examples/welcome.ts
Download https://deno.land/std/examples/welcome.ts
Warning Implicitly using master branch https://deno.land/std/examples/welcome.ts
Compile https://deno.land/std/examples/welcome.ts
Welcome to Deno 🦕

あら、かわいい恐竜が…🦕。welcome.tsは、公式のサンプルコードです。コードの内容は、URLをブラウザで開けば確認できます。
https://deno.land/std/examples/welcome.ts

ちなみに、二回目以降同じコードを実行すると、

$ deno run https://deno.land/std/examples/welcome.ts
Welcome to Deno 🦕

ダウンロードやコンパイルは実行されず、キャッシュから実行されていることがわかります。

必要なファイル/ディレクトリを用意する

Denoのインストール、TypeScriptの実行が確認できたので、早速チャットアプリを作っていきます。

まず、index.htmlserver.tsの2ファイルを用意してください。

$ mkdir deno_app
$ cd deno_app
$ touch index.html server.ts

なんと、コードを書いてチャットアプリが動くようになるまで、必要なのは上記の2ファイルだけです。Denoでは、package.jsonもnode_modulesも必要ありません。外部モジュールを利用する場合は、コード内で外部モジュールとそのバージョンをURLで指定するだけでOKです(具体的な記述方法は後ほど紹介します)。

また、TypeScript→JavaScriptの変換もDenoが内部でよしなに行ってくれるため、tsconfig.jsonも必要ありません(独自に設定したい項目がある場合は、tsconfig.jsonを作成して設定することもできます)。

クライアント側を作る

index.htmlを編集していきます。

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <title>Denoでチャットアプリ</title>
  </head>
  <body>
    <div>
      <input type="text" id="input" />
      <button id="sendButton" disabled>送信</button>
      <button id="enterButton" disabled>入室</button>
      <button id="leaveButton" disabled>退室</button>
    </div>
    <ul id="timeline"></ul>
    <script>
      let ws;
      let isConnected = false;
      const input = document.getElementById("input");
      const sendButton = document.getElementById("sendButton");
      const enterButton = document.getElementById("enterButton");
      const leaveButton =document.getElementById("leaveButton");
      const timeline = document.getElementById("timeline");
      input.addEventListener("keydown", sendMessageByEnterKey);
      sendButton.onclick = sendMessage;
      enterButton.onclick = connectWebSocket;
      leaveButton.onclick = leaveRoom;
      applyButtonState();
      connectWebSocket();
      function connectWebSocket() {
        if (ws) ws.close();
        ws = new WebSocket(`ws://${location.host}/ws`);
        ws.addEventListener("open", () => {
          isConnected = true;
          applyButtonState();
        });
        ws.addEventListener("message", ({data}) => {
          timeline.appendChild(messageDom(data));
        });
        ws.addEventListener("close", () => {
          isConnected = false;
          applyButtonState();
        });
      }
      function sendMessageByEnterKey(e) {
        if (e.keyCode === 13) sendMessage();
      }
      function sendMessage() {
        const msg = input.value;
        ws.send(msg);
        input.value = "";
      }
      function messageDom(msg) {
        const li = document.createElement("li");
        li.className = "message";
        li.innerText = msg;
        return li;
      }
      function leaveRoom() {
        ws.close();
        isConnected = false;
        applyButtonState();
      }
      function applyButtonState() {
        sendButton.disabled = isConnected ? false : true;
        enterButton.disabled = isConnected ? true : false;
        leaveButton.disabled = isConnected ? false : true;
      }
    </script>
  </body>
</html>

メッセージを入力するinput要素、送信・入室・退室のbutton要素、送受信したメッセージのタイムラインを表示するul要素を用意します。

script要素には、WebSocket接続の作成と管理、メッセージの送受信、画面の更新、ボタン要素のdisabledの制御に関する処理を記述します。ここはあまりDenoとは関係ないため、内容の解説は割愛します🦕。

サーバー側を作る

server.tsを編集していきます。

サーバーを用意する

Denoの標準ライブラリServerクラスのlistenAndServe関数を利用して、HTTPサーバーを用意します。下記の通り、listenAndServe関数をインポートし実行するコードを書きます。

import { listenAndServe } from "https://deno.land/std/http/server.ts";
listenAndServe({ port: 8080 }, async (request) => {
  if (request.method === "GET" && request.url === "/") {
    const file = await Deno.open("./index.html");
    request.respond({
      status: 200,
      headers: new Headers({
        "content-type": "text/html",
      }),
      body: file,
    });
  }
  if (request.method === "GET" && request.url === "/favicon.ico") {
    request.respond({
      status: 302,
      headers: new Headers({
        location: "https://deno.land/favicon.ico",
      }),
    });
  }
});
console.log("http://localhost:8080/");

このとおり、Denoでは外部モジュールをURLでimportすることができます。バージョンを指定して管理したい場合、

import { listenAndServe } from "https://deno.land/std@0.50.0/http/server.ts";

このように、@でバージョンを指定することもできます。lisetenAndServeの使い方は、こちらで確認できます。

ここではlisetenAndServeにオプションで8080ポートを設定し、ハンドラに下記のような処理を記述しています。

  • GETメソッドで/がリクエストされた場合、index.htmlを返す
  • GETメソッドで/favicon.icoがリクエストされた場合、Deno公式のファビコンファイルにリダイレクトする

早速サーバーを動かしてみます

$ deno run server.ts
Download https://deno.land/std/http/server.ts
Download https://deno.land/std/ws/mod.ts
〜(省略)〜
Compile file://hoge/deno_app/server.ts
http://localhost:8080/
error: Uncaught PermissionDenied: network access to "0.0.0.0:8080", run again with the --allow-net flag
    at unwrapResponse ($deno$/ops/dispatch_json.ts:43:11)
    at Object.sendSync ($deno$/ops/dispatch_json.ts:72:10)
    at Object.listen ($deno$/ops/net.ts:51:10)
    at listen ($deno$/net.ts:152:22)
    at serve (https://deno.land/std/http/server.ts:252:20)
    at listenAndServe (https://deno.land/std/http/server.ts:272:18)
    at file://hoge/deno_app/server.ts:9:1

外部モジュールのインポート、server.tsのコンパイルのあと、エラーが出ました🦕。ここで出ているエラーは、「ネットワークにアクセスする権限がありませんので--allow-netオプションをつけて再度実行してください」というものです。Denoは、デフォルトのままではネットワークにアクセスできないセキュリティルールになっています。

OK、--allow-netオプションを付けて実行します。

$ deno run --allow-net server.ts
http://localhost:8080/
error: Uncaught PermissionDenied: read access to "hoge/deno_app/index.html", run again with the --allow-read flag
    at unwrapResponse ($deno$/ops/dispatch_json.ts:43:11)
    at Object.sendAsync ($deno$/ops/dispatch_json.ts:98:10)
    at async Object.open ($deno$/files.ts:37:15)
    at async file://hoge/deno_app/server.ts:16:18

またエラーが出ました🦕。「index.htmlにアクセスする権限がありませんので、--allow-readオプションを付けて再度実行してください」とのことです。Denoは、デフォルトのままではローカルファイルにもアクセスできないセキュリティルールになっています。ンー、セキュア(適当)。

$ deno run --allow-net --allow-read server.ts
Compile file://hoge/deno_app/server.ts
http://localhost:8080/

サーバーが動き始めたようなので、ブラウザで確認してみます。

index.htmlが表示されていることが確認できました。WebSocket接続を開いただけでそれ以外の処理をなにも用意していないので、まだ何もできません。

チャットアプリの処理を用意する

次に、WebSocket接続のリクエストを受けたときの処理をserver.tsに追記していきます

import { listenAndServe } from "https://deno.land/std/http/server.ts";
import {
  acceptWebSocket,
  acceptable,
  WebSocket,
  isWebSocketCloseEvent,
} from "https://deno.land/std/ws/mod.ts";
listenAndServe({ port: 8080 }, async (request) => {
  if (request.method === "GET" && request.url === "/") {
    const file = await Deno.open("./index.html");
    request.respond({
      status: 200,
      headers: new Headers({
        "content-type": "text/html",
      }),
      body: file,
    });
  }
  if (request.method === "GET" && request.url === "/favicon.ico") {
    request.respond({
      status: 302,
      headers: new Headers({
        location: "https://deno.land/favicon.ico",
      }),
    });
  }
  if (request.method === "GET" && request.url === "/ws") {
    if (acceptable(request)) {
      acceptWebSocket({
        conn: request.conn,
        bufReader: request.r,
        bufWriter: request.w,
        headers: request.headers,
      }).then(wsHandler);
    }
  }
});
console.log("http://localhost:8080/");
const clients = new Map<number, WebSocket>();
let clientId = 0;
async function wsHandler(ws: WebSocket): Promise<void> {
  const id = ++clientId;
  clients.set(id, ws);
  dispatch(`[${id}]さんが入室しました`);
  for await (const msg of ws) {
    if (typeof msg === "string") {
      dispatch(`[${id}] > ${msg}`);
    } else if (isWebSocketCloseEvent(msg)) {
      clients.delete(id);
      dispatch(`[${id}]さんが退室しました`);
      break;
    }
  }
}
function dispatch(msg: string): void {
  for (const client of clients.values()) {
    client.send(msg);
  }
}

Denoの標準ライブラリからWebSocketの各種モジュールをインポートし、WebSocketに関する処理をいろいろと記述しています。

まず、listenAndServe関数のハンドラ内に追記した、/wsをリクエストされたときの処理について、acceptable関数でリクエストのヘッダーがWebSocketで受け入れ可能かどうかを検査し、acceptWebSocket関数でTCP接続をWebSocket接続にアップグレードしています。成功した場合、wsHandler関数を実行します。

wsHandler関数には、number型の変数をキー・WebSocket型の変数をバリューとするMapオブジェクトclientsに、ユーザーID(連番)の変数id・acceptWebSocket関数から渡されたWebSocket接続wsをclientsに追加する処理を記述しています。clientsへの追加の他に、メッセージを受信した場合はメッセージをsendするdispatch関数を実行する、isWebSocketCloseEvent関数closeイベントを検知した場合は退室の処理を行う、などの処理を記述しています。

dispatch関数でclient.send(msg)が実行されたとき、index.htmlのscript要素で定義したconnectWebSocket関数内の処理

        ws.addEventListener("message", ({data}) => {
          timeline.appendChild(messageDom(data));
        });

が実行され、画面上に入退室の通知、送受信したメッセージが表示されます。

さっそくdeno run --allow-net --allow-read server.tsし、動作を確認してみましょう。

おぉ〜🦕🦕🦕🦕

いったんここで、チャットアプリは完成とします。匿名での入退室、メッセージの送受信が可能なチャットアプリです。

まとめ

Deno、TypeScriptがすぐ動いたり、npmで苦しむことがなかったりで、かなりよいですね。今回は標準ライブラリだけでチャットアプリを作りましたが、サードパーティのモジュールもたくさんあるようなので、それらも使ってみたいですね。

採用情報

メンバーズエッジで最高のチームで最高のプロダクトを作りませんか?

最高のプロダクトをつくる 最高のチームで働く

在宅でも、地方でも、首都圏でも。多様な働き方で最高のチームをつくり、お客様のプロダクトパートナーを目指します。アジャイル開発を通じ、開発現場の第一線で活躍し続けたいエンジニアを募集しています。