🦕

DenoとFreshでペアプロ・モブプロ用タイマー『timer.team』を開発して得た知見⏰

2023/12/22に公開

DenoとFreshでペアプロ・モブプロ用タイマー『timer.team』を開発して得た知見⏰

これはDeno Advent Calendar 2023の22日目の記事です。

https://qiita.com/advent-calendar/2023/deno

はじめに

こんにちは! LEF(@lef237)と申します。

自分は今年の夏、えにしテックという会社に入社しました。そして最初のプロジェクトとして自社アプリを開発・リリースしました。

https://timer.team/

timer.team

どんなアプリかというと「ペアプロ・モブプロに特化したポモドーロタイマー」です。このアプリを開発するにあたってDenoとFreshを用いました。🦕🍋

最初にこの『timer.team』を開発した背景を説明し、それから使用技術を解説していきます。

この記事ではDenoを中心に据えつつ、広範な内容を取り上げています。自分が開発する上で詰まったポイント・理解に時間が掛かったポイントを重点的にピックアップしました。

まだDenoに触れていない方も、既にDenoに触れている方も、何かしらのTipsを得られる内容になっていれば幸いです。

概要

この記事では以下の内容を取り上げています。

  1. 『timer.team』を開発した動機
  2. 実際の画面
  3. 使用技術
  4. Denoを選択した理由
  5. esm.shとは何か? どのように使うのか?
  6. Deno標準のKVS - 「Deno KV」の紹介と実装例
  7. WebSocketの実装例
  8. Deno Deployの簡単な説明
  9. Broadcast Channelとは何か? どのような場合に使うのか?
  10. Freshの特徴とディレクトリ構造
  11. Playwrightの活用例
  12. Tabler Icons: Freshで使えるアイコン集
  13. 起点とするディレクトリの指定
  14. 404ページをカスタムしよう
  15. 開発全体を通じて学んだこと
  16. まとめ

開発の動機

えにしテックではペアプログラミング・モブプログラミングを積極的に取り入れています。👨‍💻

複数人で長時間作業に取り組んでいると、それなり疲れます。定期的に休憩を挟むことで、集中力が途切れないようにしています。

具体的には、ポモドーロ・テクニックを使っています。ポモドーロ単位で区切って、ドライバーを交代(ラウンドロビン)しています。そのため、ペアプロ・モブプロを行う際にタイマーを使います。

しかし、少しだけ不便なことがありました。それは「タイマーの共有が面倒」ということです。オンラインでペアプロ・モブプロを行うと、残時間を共有するのが難しいです。

誰かの手元のタイマーを使うと、その人に負担が集中してしまいます。残時間が他の人には見えませんし、時間が来たことを伝えなければなりません。画面共有を使って、常時タイマーを表示するのも不便です。また、音が鳴ってもビデオチャットのノイズキャンセラで相手に伝わらなかったりします。

『timer.team』導入後

『timer.team』を使うと以上の問題が解決します。

誰かがタイマーを作ります。そのURLを共有するだけで、全員が同じタイマーを閲覧・操作できます。 音も鳴ります。

QRコードが出ているので、手元のタブレットやスマホで表示するのも簡単です。

すぐに使えるように、操作もシンプルにしました。画面の下に「分」を表すボタンが並んでいます。ボタンを押すだけで、全員のタイマーが動き出します。

ブラウザのタブにも残時間が表示されるため、他のタブを開いていてもタイマーの残時間が分かります。

また、時刻が来たら音が鳴るようにも設定できます。

実際の画面

実際の画面を見てみましょう。

操作方法は簡単です。まずトップページで「Create a New Timer」をクリックします。

timer.teamのトップページ

すると、専用のタイマーが作成されます。

この状態で下の7つのボタンのどれかをクリックすると、タイマーがスタートします。

それぞれ、3分、5分、10分、15分、20分、25分、30分となっています。

実際にタイマーが動き始めた後は、このような画面になります。「一時停止」「再開」の機能ももちろんついています。

『timer.team』の特筆すべき点は、簡単に同一のタイマーを共有できるということです。このページのURLを他の方と共有するだけで、同じタイマーを操作できます。

QRコードを読み取るか、コピーボタンのアイコンをクリックすると、簡単にURLを共有できます。

音量調整も可能です。音量ボタンを押して、ミュート、音量小、音量大を順番に切り替えましょう。また、左のプラスボタンを押すと、新しいタイマーの作成もできます。

タブの表示にもこだわりました。数字が表示されるだけでなく、円グラフが欠けていくことで視覚的に残時間を確認できます。

動画

実際に、動画で使用例をお見せします。次の動画は、複数のブラウザで同じURLを開いています。

https://www.youtube.com/watch?v=Z5ySYfflU04

How to use timer.team - YouTube

動画のように、異なるブラウザ間でもタイマーが連動することを確認頂けたと思います。

ネットに接続していれば、世界中の誰とでも共有することが可能です。🌏

25分と5分のボタンを交互に押せば、複数人でポモドーロタイマー的な使い方をすることが可能です。長めの休憩を取りたいときは、10分や15分を押しましょう。

リロードしても問題ありません。同じURLであれば、最後に使用した日から一週間はタイマーが保持されます。

ペアプログラミング・モブプログラミングを行う際に、ぜひご活用ください!⏳

使用技術

『timer.team』の使用技術について簡単に列挙いたします。

  • 使用言語: TypeScript
  • ランタイム: Deno
  • フレームワーク: Fresh
  • KVS: Deno KV
  • デザイン: Twind
  • ホスティング: Deno Deploy
  • テスト: Playwright
  • CI/CD: GitHub Actions
  • タスク管理: Notion

『timer.team』ではDenoとFreshを中心に据えて開発しました。

本記事ではなるべくDeno特有の機能にフォーカスしながら、実装詳細について解説していきます。

Deno

https://deno.com/

アプリを開発するにあたってDenoを選択しました。

大きくこの3つの理由から技術スタックを決定しました。

  • Deno KVのおかげで気軽にKVS(Key-Value Store)を使える
  • Deno Deployへと簡単にデプロイできる
  • Freshを使うことでフロントエンドとバックエンドを併せて開発しやすい

WebSocketを扱いやすかったのも、Denoを選択した理由の一つです。MDN Web Docsでも、JavaScriptのサンプルコードにDenoが使われています。

https://developer.mozilla.org/ja/docs/Web/API/WebSockets_API/Writing_a_WebSocket_server_in_JavaScript_Deno

WebSocket サーバーを JavaScript (Deno) で書く - Web API | MDN

他にもDenoを使うことには色々なメリットがあります。例えばFormatterとLinter(deno fmt, deno lint)が用意されているので、迷わずに開発できました。

また、Deno用のLanguage Serverも用意されています。VSCodeの拡張機能も使いやすいので、気軽にプロジェクトを開始できると思います。

esm.sh

https://esm.sh/

esm.shはモダンなCDNです。URLからnpmパッケージをimportできます。ブラウザやDeno上で、ビルドなしにnpmパッケージを利用できます。

基本的に、次のように書くだけでnpmパッケージを使えます。

これはPreactの例です。Preactを使いたいコード(.tsx)で、次のように書きます。

import { ComponentChildren } from "https://esm.sh/preact@10.18.1";
import { useEffect, useRef } from "https://esm.sh/preact@10.18.1/hooks";

この書き方でも問題ないのですが、importのたびにURLを書くのは少し面倒です。そこで、Import Mapsを活用しましょう。Import Mapsとは、URLを別名で指定できる仕組みです。

DenoもImport Mapをサポートしています。

例えばdeno.jsonに次のように書きます。

{
  "imports": {
    "preact": "https://esm.sh/preact@10.18.1",
    "preact/": "https://esm.sh/preact@10.18.1/",

このように書くことで、.tsxファイル上ではシンプルな記述のままimportを実現できます。

import { ComponentChildren } from "preact";
import { useEffect, useRef } from "preact/hooks";

npm: specifiers

以前はesm.shのようなCDNを通じてのみnpmを使える形でしたが、最近ではnpmを直接読み込むことも可能になりました。

Deno KV

Deno KVは、Denoに組み込まれているKVS(Key-Value Store)です。Denoが動く環境であればすぐに使い始めることができて便利です。

選定理由

Deno KVを使った理由としては、次の3点が挙げられます。

  • 今回の開発に必要な機能を備えており、かつDeno Deployと統合されているのでデプロイメントをシンプルにできること
  • 利用のために外部ストレージや別途ライブラリ等をセットアップする必要がないこと
  • 開発時にDB用のサーバを起動する必要がなくシームレスに使えること

Deno標準のエコシステムのみで、これらを実現できるのは明確な強みです。

次に、Deno KVの特徴的な点を紹介します。

Keyが配列になっている

Deno KVには2つの大きな特徴があります。

1つ目の大きな特徴は「Keyが配列になっている」ということです。

https://docs.deno.com/kv/manual/

Deno KV Quick Start | Deno Docs

こちらの公式ドキュメントから引用しつつ、説明します。

const kv = await Deno.openKv();

const prefs = {
  username: "ada",
  theme: "dark",
  language: "en-US",
};

const result = await kv.set(["preferences", "ada"], prefs);

このコードの次の部分kv.set(["preferences", "ada"], prefs);で、データを保存しています。

ここで面白いのがKeyが配列になっていることです。["preferences", "ada"]この配列がKeyであり、preferencesadaとして、prefsを保存しています。

Transactions機能

このDeno KVのもう一つの魅力はTransactions機能が搭載されていることです。

Transactions | Deno Docs

『timer.team』においてもこの機能を使いました。実際のコードを引用しつつ、解説したいと思います。

以下は、タイマーを作成するときに呼び出されるメソッドです。createRoom()というメソッドによって、ペアプロ・モブプロのルームが作成しています。

【新しいルームを作成している箇所】

  public async createRoom() {
    const roomId = randomString(5);
    const roomKey = ["rooms", roomId];
    const initialState: State = { type: "initial" };

    const res = await this.kv.atomic().check({
      key: roomKey,
      versionstamp: null,
    }).set(roomKey, initialState, { expireIn: TTL_MS }).commit();
    if (!res.ok) {
      throw new Error("Room already exists");
    }

    return roomId;
  }

先ほど説明したように、Keyに階層を持たせています。["rooms", roomId]という配列がそれに当たります。

そしてconst res = await this.kv.atomic().check({から始まる一連のコードで、トランザクション処理をおこなっています。

簡単に説明すると、次のようになります。

  • atomic() → トランザクション処理を開始するよ
  • check() → 既存のKeyが存在しないか確認するよ
  • set() → データを保存するよ
  • commit() → トランザクション処理を終了するよ

このように、4つのメソッドを組み合わせるだけで簡単にトランザクション処理を実現できます。 このコードでは、乱数で生成されたIDが衝突した場合に、既に存在するタイマーを初期化する可能性を排除しています。

また、set()の中でexpireInというオプションを渡すことで、キーの有効期限を設定できます。これも便利です。

WebSocket

『timer.team』はペアプロ・モブプロに特化したポモドーロタイマーです。そのため、ユーザーが操作した場合に、同じタイマーを見ている他のユーザーにもその変化を伝える必要があります。

例えば、アリスさんとボブさんがペアプロをおこなうとします。アリスさんが25分のタイマー開始ボタンを押したら、アリスさんのブラウザ上で表示が変わるだけでなく、ボブさんのブラウザ上でもただちに表示が変わって欲しいです。アリスさんのボタンの押下と、ボブさんの画面表示の変化は、なるべくラグが少ないほうが良いでしょう。

『timer.team』では、タイマーの状態はサーバーが管理します。ユーザがタイマーを操作すると、それはサーバーに伝えられ、サーバー上でタイマーの状態が書き換わります。そして、同じタイマーを見ている全てのクライアントに対して、サーバーが更新後のタイマーの状態をpushします。このやり取りにWebSocketを利用しています。

https://lef237.hatenablog.com/entry/2023/12/04/083322

世界一わかりやすいWebSocketのサンプルコード(とその解説) - LEFログ:学習記録ノート

上の記事で、WebSocketの簡単な使い方を解説しました。ぜひ読んでみてください。

『timer.team』では、もう少し複雑なコードを書いています。

【クライアントから送られたデータをサーバーが受け取っている箇所】

  socket.addEventListener("message", async (event) => {
    const parsedData = JSON.parse(event.data);
    console.log(`${instanceId} [${roomId}] received message`, parsedData);

    const result = stateMutationSchema.safeParse(parsedData);
    if (!result.success) {
      console.log("Zod Error: ", result.error.issues);
      return;
    }

    const mutation: StateMutation = result.data;

    try {
      switch (mutation.type) {
        case "start":
          await stateRelay.start(roomId, mutation.duration);
          break;
        case "pause":
          await stateRelay.pause(roomId);
          break;
        case "resume":
          await stateRelay.resume(roomId);
          break;
      }
    } catch (error) {
      console.error(
        `${instanceId} [${roomId}] failed to apply mutation`,
        error,
      );
    }
  });

これはサーバー側のコードです。

簡単に説明すると、まずsocket.addEventListener("message", async (event) => {というコードで、クライアント(ブラウザ)からWebSocketのメッセージを受け取っています。

stateMutationSchema.safeParse(parsedData)というコードで、オブジェクトのデータ構造を検証します。Zodを使っています。型が正しくないメッセージを受け取った場合は、returnして処理を終了します。

最後に、switch (mutation.type) {というコードで、メッセージの種類に応じて処理を分岐します。

Deno Deploy

Deno Deployは、Denoのためのサーバーレスプラットフォームです。Deno Deployを使うことで、簡単にDenoのコードをデプロイできます。

https://docs.deno.com/deploy/manual/use-cases

Deno Deploy Use Cases | Deno Docs

Deno Deployを使用すると、世界中のエッジにアプリケーションをデプロイできます。上記のユースケースのページに書いてあるように、クライアントに近いサーバーへと自動的にデプロイされるので、低レイテンシー、パフォーマンスの向上、帯域幅コストの削減を見込めます。

Preview Deployments

GitHubとの連携も簡単です。GitHubのリポジトリをDeno Deployに連携することで、Pull Requestを作成する度に、プレビュー用のURLが自動で生成されます。

このURLにアクセスすると、mainブランチにマージする前に、Deno Deploy上で開発ブランチの動作確認ができます。「ローカル環境ではうまく動いていたのに本番環境ではうまく動かない」という事故を未然に防げます。

これはPreview Deploymentsと呼ばれています。

『timer.team』の開発では、このような画面を表示させました。GitHub Actionsで自動テストを回しつつ、念のためプレビューも確認していました。

またDeno Deployは、Deno公式のフルスタックフレームワークであるFreshをサポートしています。Freshについては後の項目でご説明します。

BroadcastChannel

Deno Deploy では、デプロイされたコードは複数のインスタンスで実行される可能性があります。

そのため、同じタイマーを見ているクライアントが別々のインスタンスに接続する可能性があります。インスタンスに直接接続しているクライアントに状態をpushするだけでは、他のインスタンスに接続しているクライアントがそれを受信することができません。

そこで、BroadcastChannelを用いてインスタンス間で通信を行い、同じタイマーを見ている全てのクライアントで状態を同期できるようにしています。

https://docs.deno.com/deploy/api/runtime-broadcast-channel

BroadcastChannel | Deno Docs

In Deno Deploy, code is run in different data centers around the world in order to reduce latency by servicing requests at the data center nearest to the client. In the browser, the BroadcastChannel API allows different tabs with the same origin to exchange messages. In Deno Deploy, the BroadcastChannel API provides a communication mechanism between the various instances; a simple message bus that connects the various Deploy instances worldwide.

それでは、『timer.team』で用いた実際のコードを見てみましょう。

このコードは、StateRelayクラスの一部です。タイマーの状態をサーバー間で共有するためのクラスです。

class StateRelay {
  kv: Deno.Kv;
  broadcastChannel: BroadcastChannel;
  connections: LocalConnections;

  constructor(
    kv: Deno.Kv,
    broadcastChannelName = "earth",
  ) {
    this.kv = kv;

    this.broadcastChannel = new BroadcastChannel(broadcastChannelName);
    this.dispatchBroadcastMessages();

    this.connections = new LocalConnections();
  }

  private dispatchBroadcastMessages() {
    this.broadcastChannel.addEventListener("message", (event) => {
      const { roomId, state } = event.data;
      console.log(
        `${instanceId} [${roomId}] received message from BroadcastChannel`,
        state,
      );
      this.connections.sendState(roomId, state);
    });
  }

まず、broadcastChannelName = "earth"としています。このearthを使って、オブジェクトを作成new BroadcastChannel()するように設定しています。

同じbroadcastChannelNameを持つチャンネル同士で情報が共有されます。今回は全てのサーバーで全情報を共有したかったため、単一のearthを指定しました。

そして、this.broadcastChannel.addEventListener("message", (event) => {によって、コンストラクタで作成したBroadcastChannelオブジェクトからメッセージを受け取っています。他のインスタンスが送信したメッセージを、ここで受信しています。

次に、メッセージの送信部分を見てみましょう。

これは同じStateRelayクラス内部の、タイマースタートに関するメソッドです。

  private async updateState(
    roomId: string,
    updater: ((state: State) => State) | State,
  ): Promise<void> {
    const roomKey = ["rooms", roomId];
    const resGet = await this.kv.get<State>(roomKey);
    if (resGet.value === null) {
      throw new Error("Room doesn't exist");
    }

    const newState = (updater instanceof Function)
      ? updater(resGet.value)
      : updater;

    const resSet = await this.kv.atomic().check(resGet).set(roomKey, newState, {
      expireIn: TTL_MS,
    }).commit();
    if (!resSet.ok) {
      throw new Error("Failed to update state");
    }

    this.connections.sendState(roomId, newState);
    this.broadcastChannel.postMessage({ roomId, state: newState });
  }

  public async start(roomId: string, duration: number) {
    const willStopAt = Date.now() + duration;
    await this.updateState(roomId, { type: "running", willStopAt, duration });

    console.log(
      `${instanceId} [${roomId}] timer started; will stop at ${willStopAt}`,
    );
  }

複雑そうに見えますが、大事な部分だけを取り出して解説します。

ポイントはここです。

this.broadcastChannel.postMessage({ roomId, state: newState });

このpostMessage()メソッドによって、データを送信しています。データの中身は、「対象のroomIdに対して新たなstateをセットする」というものです。

  1. 以前の節で解説したWebSocketでは、同じサーバー内の複数のクライアントとデータを送受信しています。
  2. 今回の節のBroadcastChannnelでは、Deno Deploy内の複数のインスタンスとデータを送受信しています。

この2つを組み合わせることで、同期するタイマーをサーバーレス環境で実現しています。

Deno KVのwatch()メソッドについて

2023-12-11、Deno v1.38.5がリリースされました。そこで新たに追加されたのが、watch()メソッドです。

この機能を使うと、Deno KVに保存したデータの変更をそれぞれのインスタンスで捕捉できます。そのため、上記のBroadcastChannelを使わなくとも、KVSに保存したデータの変更をインスタンス間で共有できます。

Denoの進化を感じさせるリリースでした。この『timer.team』は2023年の8月〜11月に掛けて開発したため、このwatch()メソッドは使えませんでした。後日、時間を取ってwatch()を使った実装への置き換えも試してみたいと思います。

Fresh

https://fresh.deno.dev/

Deno公式のフルスタックフレームワークです。ビルドステップがなく、エッジでのJIT(just-in-time)レンダリングを特徴とします。PreactとJSXを採用しているため、Reactに慣れた方なら簡単にコードを書き始められるでしょう。

Islands Architectureを採用しているため、同じページ内に静的なコンポーネントと動的なコンポーネントを組み合わせることができます。

routes/ディレクトリの中に、静的に表示したいコンポーネントを配置します。また、api/ディレクトリの中に、APIのエンドポイントも配置できます。

動的に表示したいコンポーネントは、islands/ディレクトリの中に配置します。これらは、routes/ディレクトリのコンポーネントから呼び出すことができます。

このように、Freshではディレクトリを分けることで、レンダリング方法を分けています。

『timer.team』のディレクトリ構造

『timer.team』では、具体的に次のようなディレクトリ構造にしました。Fresh標準のディレクトリ構造を少しカスタムしています。

- .github/
- .vscode/
- client-lib/
- components/
- islands/
- lib/
- playwright/
- routes/
  - api/
- static/
  • クライアント側で使う汎用的なコンポーネントは、components/ディレクトリに配置しています。islands/ディレクトリには中心的なコンポーネント置いています。
  • client-lib/には、クライアント側で共通して使われるコードを置いています。カスタムフック等がそれにあたります。
  • lib/には、サーバー側で共通して使われるコードを置いています。上記のStateRelayクラスもここにあります。
  • playwright/には、E2Eテストのコードを置いています。Playwrightを使っています。
  • static/には、静的なファイルを置いています。FaviconやSVG, MP3ファイルをここに置くと、/{ファイル名}でアクセスできます(Freshのデフォルトでそのようになっています)。
  • routes/には、サーバー側でレンダリングするファイルを置いています。『timer.team』ではトップページや共通のレイアウトがそれに該当します。
  • api/には、APIのエンドポイントを置いています。この記事のWebSocketの節に出てきたコードはここに置いています。

Playwright

https://playwright.dev/

Playwrightを導入した経緯

このアプリでは、インタラクティブな動作を必要とします。

同じIDのタイマーに複数のクライアントが接続している場合を考えます。一つのクライアントがタイマーを開始したとき、他のクライアントにもタイマーの開始が伝わります。つまり、ブラウザの描画が同時に変化するということです。

この挙動を毎回手作業で確認するのは大変です。そのため、E2Eテストを実装しました。

E2Eテストには色々な種類がありますが、今回はPlaywrightを使うことにしました。Playwrightのディレクトリだけ、Node.jsで動かしています。

Playwrightの使い勝手はとても良く、ちょっと込み入ったテストも実装することができました。以下に紹介します。

複数のブラウザを同時にテスト

AliceとBobが同じタイマーに接続しているとします。Aliceがタイマーをスタートしたとき、Bobが見ているタイマーも動き出すことを確認するテストケースを考えます。

Playwrightでは、browser.newContext()を使ってブラウザのコンテクストを生成することができます。実際のコードを見てみましょう。

test("Two browsers are in sync", async ({ browser }) => {
  const aliceContext = await browser.newContext();
  const bobContext = await browser.newContext();

  const alicePage = await aliceContext.newPage();
  const bobPage = await bobContext.newPage();

  // Alice creates a timer
  await createNewTimer(alicePage);
  await expect(alicePage.locator("main")).toHaveText("00:00");

  // Bob joins the timer
  await bobPage.goto(alicePage.url());
  await expect(alicePage.locator("main")).toHaveText("00:00");

  // Alice starts the timer
  await alicePage.getByRole("button", { name: "25" }).click();

  // Bob sees the timer running
  await expect(bobPage.locator("main")).not.toHaveText("00:00");
});

AliceとBobのブラウザコンテキストを生成し、同じIDを持つタイマーのページにアクセスしています。そして、Aliceの側でスタートボタンを押して、Bobの側で動いていることを確認しています。

公式ドキュメントでは次のページが該当します。詳しい使い方はこちらをご参照ください。

画面サイズごとのテスト

Playwrightでは画面サイズごとのテストも簡単です。例えば、次のようなコードを書きました。

  test("Show Tooltip after creating timer", async ({ page }) => {
    await page.setViewportSize({ width: 375, height: 667 }); // iPhone SE size
    await expect(
      page.getByRole("banner").locator(
        "button .text-primary-content",
      ),
    ).toHaveText("Timer created. Share this!");

    await page.setViewportSize({ width: 800, height: 700 }); // PC size
    await expect(
      page.getByRole("banner").locator(
        ".gap-2 .text-primary-content",
      ),
    ).toHaveText("Timer created. Share this!");
  });

このsetViewportSize()というメソッドで、画面サイズを変更できます。これにより、画面サイズごとの動作を確認できます。

クリップボードのテスト

Playwrightでクリップボードの中身を確認するにはちょっと工夫が必要です。

『timer.team』ではURLの共有が重要な機能です。URLをコピーするボタンが存在します。

そのボタンを押したとき、クリップボードに意図した値がコピーされるかをテストしたいと考えました。

以下はそのテストコードを抜粋したものです。

test.describe("Clipboard copy button", () => {
  // Only chromium seems to support clipboard access
  test.skip(({ browserName }) => browserName !== "chromium");

  test("Copy URL and icon changes", async ({ browser }) => {
    // Allow reading and writing of the clipboard
    const context = await browser.newContext({
      permissions: ["clipboard-read", "clipboard-write"],
    });

    const page = await context.newPage();
    await createNewTimer(page);

    // (中略)

    // check if the clipboard contains the current url
    const clipboardContents = await page.evaluate(
      "navigator.clipboard.readText()",
    );
    expect(clipboardContents).toMatch(page.url());

    // after 1.0 seconds, the icon should change back to clipboard copy
    await page.waitForTimeout(1000);
    await expect(page.locator(".icon-tabler-check")).toBeHidden();
    await expect(page.locator(".icon-tabler-clipboard-copy")).toBeVisible();
  });
});

pageに対してevaluate()というメソッドを呼び出すことで、ブラウザのコンテキストでJavaScriptを実行しています。

ここで注意が必要なのが、permissionsの設定です。permissionsclipboard-readclipboard-writeの両方を指定する必要があります。

テスト対象のアプリーケーションがクリップボードに書き込めるようにするためにclipboard-writeが、Playwrightからクリップボードの値を読み取るためにclipboard-readが必要です。これらを忘れてしまうと、パーミッションが与えられず、テストが失敗します。

公式ドキュメントのこのページに詳細が書いてあります。

また、こちらの記事が参考になりました。🙏

Twind

https://twind.dev/

このアプリでは、Twindというライブラリを使っています。Freshのバージョン1.5までは、デフォルトでTwindを使うようになっていました。

TwindはTailwind CSSととても似ています。ビルドステップのない、とても小さなコンパイラーであることを特徴としています。

ちなみに12月の最近のアップデートで、Freshが本家のTailwind CSSに対応しました。

This means we’re putting our Twind plugin on life support and mark it as deprecated.

上記の引用によると、Fresh公式としてはTailwind CSSへの移行を勧めているようです。そのため、これから開発を始める方は、Tailwind CSSを選ぶと無難かもしれません。

Tabler Icons

https://tabler.io/icons

このアプリでは、Freshが公式に勧めているTabler Iconsというアイコンセットを使っています。

実際に『timer.team』での活用例を見てみましょう。

まず、このアプリのdeno.jsonimportsに次の行を書きました。これはimport mapsの設定です。

"tabler_icons_tsx/": "https://deno.land/x/tabler_icons_tsx@0.0.3/tsx/",

実際にアイコンを使っている箇所を見てみましょう。URLのコピーボタンを表示する場合、以下のようにしてアイコンのコンポーネントをimportします。

import IconClipboardCopy from "tabler_icons_tsx/clipboard-copy.tsx";

そして、このコンポーネントを使います。

<IconClipboardCopy size={42} stroke={1.5} />

実装の表示は次のようになります。

TSX Tabler Iconsで検索すると、好みのアイコンを簡単に探せます。

気になる単語で検索すると、うってつけのアイコンが見つかります。次の画像は「clipboard」で検索した例です。ここでIconClipboardCopyを見つけました。

検索結果を元にコンポーネント内に書き込むだけで、簡単にアイコンを使うことができます。

アイコンの微調整

アイコンの表示を微調整したい場合もあるでしょう。例えば、アイコンのサイズを小さくしたり、線の太さを変えたいと思うかもしれません。

これはアイコンを表示するコンポーネントに、オプションを渡すことで実現できます。

例えば、上記のIconClipboardCopyでは、sizestrokeという2つのオプションを渡しています。

詳しいオプションについては以下のリンク先をご参照ください。

https://github.com/tabler/tabler-icons/#react

ちなみに、実装元のコードはこのようになっています。細かく確認したい方は、コードジャンプを使うと良いでしょう。

起点とするディレクトリの指定

Next.jsでは、モジュールパスのエイリアスを作成できます。

Advanced Features: 絶対パスインポートとモジュールパスエイリアス | Next.js

Freshの開発においても、起点とするディレクトリをエイリアスとして指定することが可能です。

deno.jsonファイルの"imports":の中に、次の行を追加しましょう。

"@/": "./",

これにより、@/というパスを使って、プロジェクトの起点となるディレクトリを指定できます。

この設定を導入するまでは、../../../のような相対パスを使っていました。

Freshの公式ドキュメントではなく、Denoの公式ドキュメントに書いてあります。気づくまでに時間が掛かったので書き記しておきます。

Example - Using project root for absolute imports | Deno Docs

404ページをカスタマイズする

Freshでも404ページを設定できます。routes/_404.tsxというファイルに、カスタム404ページのコードが書かれています。このページを編集することで、404ページの見た目を整えられます。

今回のアプリでは、その404ページをカスタマイズしました。

具体的には、404ページに渡されるデータの中のtimertrueのときはTimerNotFoundコンポーネントを、そうでないときはPageNotFoundコンポーネントを表示するようにしました。

実際のコードを使って解説します。

    if (!await stateRelay.isRoomActive(id)) {
      return ctx.renderNotFound({ timer: true });
    }

これは、もしルームが存在しない場合にctx.renderNotFound({ timer: true });を呼び出すコードです。{ timer: true }というオブジェクトを渡しています。

renderNotFound()メソッドによって、404へとデータを渡しつつ、routes/_404.tsxをレンダリングできます。

import { Head } from "$fresh/runtime.ts";
import { UnknownPageProps } from "$fresh/server.ts";

function PageNotFound() {
  return (
    <>
      <Head>
        <title>404 - Page not found</title>
      </Head>
      <div class="bg-base-100 h-[100dvh] w-[100dvw] text-primary-content flex justify-center items-center">
        // 中略
      </div>
    </>
  );
}

function TimerNotFound() {
  return (
    <>
      <Head>
        <title>404 - Timer not found</title>
      </Head>
      <div class="bg-base-100 h-[100dvh] w-[100dvw] text-primary-content flex justify-center items-center">
        // 中略
      </div>
    </>
  );
}

export default function Error404({ data }: UnknownPageProps) {
  if (data && data.timer) {
    return <TimerNotFound />;
  } else {
    return <PageNotFound />;
  }
}

data && data.timerで条件分岐しています。dataが存在し、かつdata.timertrueのときはTimerNotFoundコンポーネントを、そうでないときはPageNotFoundコンポーネントを表示します。

これによって、存在しないタイマーにアクセスしたときに表示される404と、存在しないページにアクセスしたときに表示される404を分けることができます。

【存在しないタイマーにアクセスしたとき】

【存在しないページにアクセスしたとき】

開発全体を通じて学んだこと

ドキュメントを読む力

基本的に、ドキュメントにすべてが書かれています。

しかし、そのドキュメントを読み解くためには、地道な努力が必要だと分かりました。

既存のコードを参考にして新しいコードを書くのは比較的簡単です。しかし、何もないゼロの状態からコードを書くためには、使用する技術の仕様をきちんと把握する必要があります。技術仕様を把握するためには、体系的な理解が必要です。「なんとなく」でドキュメントを読むと、壁に突き当たってしまいます。

それまでの自分がいかに、過去にうまくいった類似の解決策に囚われていたかを自覚しました。過去の経験ばかりに頼っていると、未知の問題が発生したときに対処できなくなってしまいます。また、「闇雲に手を動かす」というアンチパターンにも繋がってしまいます。

上記のWebSocketやBroadcastChannelも、きちんと理解した上で使えば応用が効きます。深い洞察を得るためには、慌てずにドキュメントを読むことが大事だと分かりました。

まれに、ドキュメントに書いていない問題に直面する場合もあります。そのときは公式リポジトリのソースコードやIssueを確認すると、解決策が見つかることがあります(OSSを読むことそのものが大きな学びになります)。

闇雲に手を動かさない

「うーん、どうやって実装すればいいのか分からないな〜。とりあえずここにこのCSSを書き込んで、どんなふうに表示されるか見てみるにゃ〜」

このように「とりあえず」で手を動かしてしまうのが良くないことを、開発を通じて学びました。「とりあえず」「試しに」「なんとなく」……このような理由で手を動かすのは黄色信号です。

そうではなく、まずは仮説を立てます。その仮説を検証するために手を動かします。そして、(成功しても失敗しても)その結果を分析します。修正が必要なら「片付け」をします。このようなサイクルを回すことで、開発を進めていきます。

「とりあえず」で手を動かしても上手くことはあります。しかしそれは、運良く動いただけかもしれません。もしかすると、そのコードは将来的に問題を引き起こすかもしれません。そうならないためにも、きちんと仮説を立ててから手を動かすことが大事だと分かりました。

もちろん、新しい技術を学ぶ際に色々手を動かして記憶に定着させるフェーズ(小さな失敗をすることで理解を早めるフェーズ)は必要です。しかし、それをずっと続けていてはいけないと気づきました。

設計とリファクタリング

開発において「どのように設計するか」「どのようにリファクタリングするか」を考えることは大事です。

新しい機能を実装する前に、「その機能を実装しやすいように準備する」ことで、開発がスムーズに進みます。例えばあらかじめオブジェクトのデータ構造を変えておくことで、その後の処理が簡単になるかもしれません。

また、新しい機能を実装した後に、「本当にその形が理想形か」を考えることで、スパゲッティコードを防ぐことができます。例えば、コードの重複をなくすことで、コードの見通しが良くなるかもしれません。そもそも、実装する位置が間違っているかもしれません。モジュールやコンポーネント、クラスの切り分け方が(開発の進みとともに)歪んでしまった可能性もあります。もっと良い命名があるかもしれません。

機能を実装できたときの満足感はひとしおですが、そこで思考を止めずに、しばらくセルフレビューする時間を設けて、自分自身と問答することが大事だと分かりました。

まとめ

今回、3つのパートに分けてDenoとFresh、およびDeno Deployでの開発をまとめてみました。

  • Deno Deployでリリースした「ペアプロ・モブプロ用タイマー」の紹介
  • Deno, Fresh, Deno Deployを用いた開発におけるTips
  • アプリ開発全体を通じて新人プログラマー(@lef237)が得た知見

『timer.team』は3人のチームで開発しました。自分1人では開発しきれなかったと思います。この記事の知見を得られたのも、ペアプロを繰り返し、コードを細かくレビューして頂いたおかげです。この場を借りて、改めて @darashi さんと @mayuco さんに感謝いたします。

Preactを使ったフロントエンドの具体的な実装についても解説したかったのですが、Deno/Freshに関する内容で統一感を持たせたかったので、今回は割愛しました。

長文を最後まで読んでいただきありがとうございました。開発の参考になれば幸いです。🍋

Discussion