Deno製のWEBフレームワーク Fresh でチャットアプリを作成するまでの軌跡 〜第二章〜
はじめに
数日前に、以下のような記事を書きました。
前回は、Denoの特徴を深掘りし、基礎を習得することに専念しました。
今回は DenoのフルスタックWebフレームワークFreshを使ってサンプルアプリを製作し、Deno Deploy を使ったプロダクションデプロイまで実施してみた結果をまとめていきたいと思います。
Freshについて
スピード、シンプルさ、信頼性を重視して作られたWEBフレームワークです。下記特徴があります。

一つ一つを理解し、自分の言葉で解釈した内容を下記にまとめます。
Just-in-time rendering on the edge.
Freshでは全てのページはサーバサイドレンダリングです。デフォルトではクライアントサイドがJavaSvriptを実行しない作りになっています。クライアントサイドであるブラウザは素のHTMLをサーバから受け取って描画するだけです。その方がFreshの基本理念であるスピード・パフォーマンスを向上させることができるからです。
とはいえ、WEBページである以上、JavaScriptの実行が必要な時も必ずあります。「そこどうしてんの?」という話は Island based client hydration for maximum interactivity.で解説しています。
また、エッジという表現についてですが、FreshはDeno Deployで動く前提で作られています。サーバ側で組み立てたHTMLをCDNから配信するという意味でエッジという表現を用いているのだと思います。
Island based client hydration for maximum interactivity.
Islands Architectureモデルで最大限のインタラクティブ性を実現していると言っています。
従来型のクライアントサイドレンダリングなSPAの場合は、ブラウザにJavaScriptフレームワークを送信、実行して、サーバ側から送られたデータを基にDOMを生成し、WEBページが表示されるという処理が行われていました。
一方で、前述したようにFreshでは、サーバサイドでHTMLが生成され、それがブラウザに送られてくるため、ブラウザは受け取ったHTMLをすぐに表示できます。パフォーマンス面で言えば、Freshの方に軍配が上がります。
しかし、これではWEBフォームなどでユーザと対話的なやりとりが実現できません(例えば、リアルタイム性のあるバリデーションなど)。UXに欠けた質素なWEBページとなってしまいます。
そこでFreshでは、そういったインタラクティブ性が必要な箇所だけ、クライアントサイドでレンダリングさせ、残りのstaticな部分はサーバサイドでレンダリングさせるという手法を取っています。
これをIslands Architectureモデルと言います。このモデルの解説は後述しています。
Zero runtime overhead
Just-in-time rendering on the edge.のセクションで記述した
Freshでは全てのページはサーバサイドレンダリングです。デフォルトではクライアントサイドがJavaSvriptを実行しない作りになっています。クライアントサイドであるブラウザは素のHTMLをサーバから受け取って描画するだけです。
という内容そのままを受け取って貰えば大丈夫です。クライアントサイドでJavaScriptを実行しないため、ラインタイムのオーバヘッドが0だと言っているのだと思います。
ただこれだけだと、インタラクティブなユーザーインターフェイスには不向きなので、Islands Architectureモデルを導入しているという感じです。
No build step.
散らばったHTMLタグやJavaScriptなどをバンドルして・・・のような工程が不要だと言っています。
確かに、DenoDeploydで成果物をデプロイして動作検証した時もビルドの工程はありませんでした。
エントリーポイントとなるmain.tsをDenoDeployに教えてあげるだけでした。
No configuration necessary.
例えば、React+Node.jsで開発初めるぞー!となった時に初期設定が色々あると思います。フォーマッター入れたり、Lint入れたり・・・
Deno+Freshでは、デフォルトで色々なツールも準備してあるので、その辺のコードベース作成的な作業負荷が小さいよ、と言っているのだと思います。
確かに、deno lint、deno fmtといったそこそこ充実したツールが準備してありますし、開発を始める上での敷居の低さはあります。
TypeScript support out of the box.
私が以前書いた「Deno製のWEBフレームワーク Fresh でチャットアプリを作成するまでの軌跡 〜第一章〜」のTypeScriptを標準でサポートしている辺りと類似しているかと思います。
特徴を理解したところで開発開始
まずは公式ドキュメントから読みました。
色々書いてありますが、大事なことを列挙すると以下だと思います。
-
Freshを使うには、Deno1.25.0以降がインストールされている事 -
FreshはIslands Architectureモデルを採用している事 -
FreshでのパスルーティングはNext.js等で見られるファイルベースルーティングと類似している事 - 開発者は
Fresh製Webアプリケーションをインターネットに迅速かつ簡単にデプロイ可能である事
1. Freshを使うには、Deno1.25.0以降がインストールされている事
これについては特段言及することはありません。公式ドキュメントにインストール方法が記載されているので参考にしてください。
2. FreshはIslands Architectureモデルを採用している事
Islands Architecture?これなんぞ?
WEBページにおいてstaticな部分 は、サーバサイドでHTMLをレンダリングしてクライアントに返し、
JavaScriptの実行が必要な部分 はクライアントサイドでレンダリングさせる手法のことです。
JavaScriptを必要としない部分にはJavaScriptを読み込ませないようにします。
今回製作したチャットアプリのトップページは下記のように考えることができます。

WEBページの大部分はstaticなHTMLで構成されており、それらを極力サーバサイドでレンダリングし、動的なアプリの部分のみJavaScriptが実行されるようにすることでパフォーマンスを向上させています。
一方でページ遷移の度に、ブラウザからサーバへのリクエストが実行されることになるので、ルーティングの体験がやや悪くなるという指摘もあります。が、そこまで体感は変わらないというのが個人的な感想です。
実装方法
例えば、routes/index.tsxで islandコンポーネントであるislands/Header.tsx を読み込みたい場合を考えます。
下記手順で実装が可能です。
1. island直下にHeader.tsxを作成する
全てのページはサーバー側でレンダリングされますが、前述したようにislandコンポーネントを作成することにより、クライアント側でもレンダリングされる仕組みを利用することができます。
island直下に作成したtsxファイルは、Freshにおいてislandコンポーネントと解釈されます。
それをどうやってFreshが解釈するのか?についてですが、fresh.gen.tsのマニュフェストで統括しているようです。
const manifest = {
routes: {
"./routes/_404.tsx": $0,
"./routes/api/connect/index.ts": $1,
"./routes/api/read/index.ts": $2,
"./routes/api/send/index.ts": $3,
"./routes/auth/index.tsx": $4,
"./routes/create/rooms/index.tsx": $5,
"./routes/create/users/index.tsx": $6,
"./routes/error/index.tsx": $7,
"./routes/index.tsx": $8,
"./routes/room/[room].tsx": $9,
},
islands: {
"./islands/Chat.tsx": $$0,
"./islands/Header.tsx": $$1,
},
baseUrl: import.meta.url,
config,
};
island直下にファイルを追加すると、自動的に上記fresh.gen.tsに追記されます。
Freshプロジェクト作成時に予め準備されているファイルやディレクトリについては公式ドキュメントを参考ください。
2. Header.tsxを完成させる
下記のようなコンテンツとユーザメニューを持ったヘッダを作成してみました。
import {tw} from "twind";
import {asset} from "$fresh/runtime.ts";
export default function Header({hContents, userName}: { hContents: string; userName: string; }) {
window.onclick = (event: MouseEvent) => {
if (event.target.matches('.user-menu-dropdown')) {
document.getElementById("dropdown").classList.toggle("show");
} else {
const dropdowns = document.getElementsByClassName("dropdown-content");
for (let i = 0; i < dropdowns.length; i++) {
const openDropdown = dropdowns[i];
if (openDropdown.classList.contains('show')) {
openDropdown.classList.remove('show');
}
}
}
}
return (
<>
<div className={tw("flex justify-between items-center")}>
<h1 className={tw("font-extrabold text-5xl text-gray-800")}>{hContents}</h1>
<div className="dropdown">
<button className={"hover:bg-gray-400 rounded-xl p-2 dropbtn user-menu-dropdown"}>
<img alt={`/${userName}`} src={asset(`/${userName}.svg`)}
className="shadow-lg rounded-full mx-auto max-w-120-px w-12 h-12 user-menu-dropdown"/>
<div className="pt-2 text-center">
<h5 className="text-xl font-bold text-blueGray-700 user-menu-dropdown">{userName}</h5>
</div>
<div id="dropdown" className="dropdown-content">
<a href="/auth">ログアウト</a>
<a href="/create/users">ユーザ管理</a>
</div>
</button>
</div>
</div>
</>
)
}
イベントハンドラーであるwindow.onclickは、islandコンポーネント上でのみ実行されます。routes以下に作成したtsxファイルに対して、イベントハンドラーを含むJavaScriptを書いても実行されません。
3. islandコンポーネントを読み込み、HTMLに埋め込む
・
・
中略
・
・
import Header from "../islands/Header.tsx";
・
・
中略
・
・
export default function Home({data}: PageProps<Data>) {
const twIsRead = tw("bg-green-500 text-white font-bold py-2 px-4 rounded-3xl");
const twIsNotRead = tw("bg-red-500 text-white font-bold py-2 px-4 rounded-3xl");
return (
<div className={tw("min-h-screen bg-blue-100")}>
<Head>
<title>fresh sample</title>
<link rel="stylesheet" href="/css/style.css"/>
</Head>
<div
className={tw(
"max-w-screen-sm mx-auto px-4 sm:px-6 md:px-8 pt-12 pb-20 flex flex-col"
)}
>
+ <Header hContents={"Home"} userName={data.loggedInUserName} />
<section className={tw("mt-8")}>
<div className={tw("flex justify-between items-center")}>
<h2 className={tw("text-3xl font-bold text-gray-800 py-4")}>トークルーム一覧</h2>
以上でクライアント側でもレンダリングされるislandコンポーネントを作成する事ができます。
3. FreshでのパスルーティングはNext.js等で見られるファイルベースルーティングと類似している事
Freshでのパスルーティングはシンプルです。今回のチャットアプリにおけるファイル名、ルートパターン、一致するパスの組み合わせは以下表のようになります。
ファイル名 |
ルートパターン |
一致するパス |
メモ |
|---|---|---|---|
routes/index.tsx |
/ |
/ |
|
routes/room/[room].tsx |
/room/:roomId |
/room/1, /room/21
|
|
routes/auth/index.tsx |
/auth |
/auth |
|
routes/create/rooms/index.tsx |
/create/rooms |
/create/rooms |
|
routes/create/users/index.tsx |
/create/users |
/create/users |
|
routes/error/index.tsx |
/error |
/error |
|
routes/_404.tsx |
ー | ー | サーバから404を受領した場合に自動でこのページが表示される |
routes/_app.tsx |
ー | ー | 全ルーティングに適応されるページ。titleタイトルタグ等を一括して設定可能 |
routes/api/connect/index.ts |
/api/connect |
/api/connect |
|
routes/api/read/index.ts |
/api/read |
/api/read |
|
routes/api/send/index.ts |
/api/send |
/api/send |
ちなみに、一致するパスから対象のファイルが選択されますが、対象のtsxにカスタムハンドラというものを定義することができます。
カスタムハンドラは、Request => ResponseまたはRequest => Promise<Response>の形式の関数であり、カスタムハンドラを定義しない場合は、ページコンポーネントをレンダリングするだけのデフォルトハンドラを使用することになります。
以下のような感じでカスタムハンドラを記述しておくと、ページがGETされた時にDBからリソースを取得したり、セッションCookieの検証を実施したりすることができます。
・
・
中略
import {HTTP_STATUS_CODES} from "../constant/index.ts";
type Data = {
rooms: Room[];
loggedInUserName: string;
}
export const handler: Handlers<Data> = {
async GET(req: Request, ctx) {
let payload: Payload;
try {
payload = await checkSession(req);
} catch (_) {
const response = new Response("", {
status: HTTP_STATUS_CODES.REDIRECT,
headers: {Location: "/auth"},
});
deleteCookie(response.headers, "token");
return response;
}
const rooms = await findAllRoom(payload.name);
return ctx.render({
rooms: rooms,
loggedInUserName: payload.name as string,
});
}
};
export default function Home({data}: PageProps<Data>) {
const twIsRead = tw("bg-green-500 text-white font-bold py-2 px-4 rounded-3xl");
const twIsNotRead = tw("bg-red-500 text-white font-bold py-2 px-4 rounded-3xl");
・
・
中略
・
・
<Header hContents={"Home"} userName={data.loggedInUserName} />
カスタムハンドラ内で取得したデータをHTMLに渡すことにより、データ取得後に画面を描画するというスキームを実現することができます。
上記では、Dataという型エイリアスを持ったPropsをGETカスタムハンドラからHTMLに渡しており、islandコンポーネントであるHeader.tsxがdataをパラメータでもらっている事が分かります。
さらに公式ドキュメントには
ハンドラーはctx.render() を呼び出す必要はありません
という記載があります。つまり以下のようなページを返さないカスタムハンドラを定義することも可能ということになります。POSTリクエストを受け付けるようなAPIも定義可能ということですね〜。
import {Handlers} from "$fresh/server.ts";
import {ApiSetReadMessage} from "../../../types/index.ts";
import {markMessageAsRead} from "../../../communication/database.ts";
export const handler: Handlers = {
async POST(req, _ctx) {
const data = (await req.json()) as ApiSetReadMessage;
try {
await markMessageAsRead(data.userName, data.roomId);
} catch (e) {
console.log({e});
}
return new Response("OK");
},
};
4. 開発者はFresh製Webアプリケーションをインターネットに迅速かつ簡単にデプロイ可能である事
スピード!スピード!スピード!と3回書きたくなる位、あっという間にプロダクションデプロイできました。
1. Deno Deployにアクセスする
Deno Deployにアクセスし、Sign upを選択してください。

2. アカウント連携の実施
以下のようなログインフォームが表示されるので、GitHubのログイン情報を入力し、サインインしてください。

3. プロジェクトを作成する
アカウント連携が成功すると、下記のような画面に到達できます。New Projectを選択してください。

4. Deploy from GitHub repository
GitHubリポジトリにリンクさせて、masterブランチへのpush時にデプロイを有効にします。それぞれ入力する項目を解説します。

①
リポジトリとプロジェクトを選択します。プライベートなレポジトリでも選択可能です。
②
プロダクションコードのエントリーポイントとなるファイルを指定します。必ずmain.tsを選択してください。
③
プロジェクトに名前を付けることができます。URLのドメイン部にも関連してきます。
④
環境変数の上書きが可能です。右下のAdd Env Variableから追加が可能です。
⑤
①〜④が入力し終わったら、Linkを押下してください。程なくしてプロジェクトの作成が完了します。
5. デプロイされたプロジェクトにアクセスして動作検証する
デプロイが完了すると、以下のページに到達します。

AnalyticsやLogsのタブでは、デプロイしたアプリのアクセス状況やログを参照することが可能です。
Deploymentsタブでは、直近のデプロイがいつ実施されたのか管理でき、SettingsではProject Nameの変更、環境変数の追加・削除・変更、ドメインの追加、プロジェクトの削除(DANGER ZONE)などを実施することが可能です。
発行されたURLにアクセスしてみると、無事アクセスすることができました!
最初はログインページにリダイレクトされる

ログインするとHOME画面に着地

ちょっと話は脱線しますが・・・
deno.landに公開されているモジュールを試しに使ってみたので、ついでに記載しておきます。
今回、WEBフォームからユーザ入力値を受け取って、DBにリソース作成する処理があったので、入力値を検証するバリデーションライブラリについて、Denoの標準ライブラリから選定して使ってみました。
使ったライブラリ
WEBフォーム周りの実装
ログインフォームの実装でユーザから入力されたIDとPWを検証するバリデータを定義してみました。
PWを生の状態で管理している件についてはご容赦ください。CSRF対策は実施しています。
中略
async POST(req: Request, ctx) {
const form = await req.formData();
const csrfToken = form.get("csrf_token") as string || "";
const username = form.get("username") as string || "";
const password = form.get("password") as string || "";
const cookies = getCookies(req.headers);
const csrfCookieToken = cookies.csrf_cookie_token || "";
const verifyTokenResult = verifyToken(
csrfToken.toString(),
csrfCookieToken,
);
if (!verifyTokenResult) {
return pageLoad(ctx, "認証に失敗しました");
}
+ const [passes, errors] = await validators.userAuthentication({ username, password });
const firstErrors = firstMessages(errors);
if (!passes) {
return pageLoad(
ctx,
username,
password,
firstErrors.username as string ?? "",
firstErrors.password as string ?? "",
undefined
);
}
ユーザ作成時のバリデーションも同じ処理を使い回したかったため、validator/index.tsにuserAuthenticationという関数を定義し、その中でvalidasaurを使ったバリデーションを実装してみました。
import {match, required, validate} from "validasaur";
// 'ユーザ作成時'及び'ログイン時'にuserAuthenticationを使用
export const userAuthentication = async (input: { username: string, password: string }) => {
+ return await validate(input, {
+ username: [required, match(/^[0-9a-zA-Z]{1,20}$/)],
+ password: [required, match(/^[0-9a-zA-Z]{4,20}$/)],
+ }, {
+ messages: {
+ "username.required": "ユーザ名は必須入力項目です",
+ "username.match": "ユーザ名が入力条件を満たしていません",
+ "password.required": "パスワードは必須入力項目です",
+ "password.match": "パスワードが入力条件を満たしていません",
}
});
};
上記のような感じで実装することができます。個人的には視認性が良いし、エラーメッセージも勿論カスタマイズ出来るので気に入っています。
Tips
404ページについて
routes/_404.tsxを定義しておくと、サーバ側から404ステータスコードを受け取った時には独自の404NotFoundページを表示できます。私は下記のようなページを作成しました。

環境変数について
dotenv/load.tsをインポートすることで自動的に.envファイルを読み込み可能です。
以下のようにしてDeno.envから環境変数を読み込みます。
import "dotenv/load.ts";
const dummy = new Dummy({
user: Deno.env.get("USER_ID"),
password: Deno.env.get('PASSWORD')
});
USER_ID=*******
PASSWORD==*******
CSRF_KEY==*******
JWT_CRYPTO_KEY==*******
また、DenoDeployのUIで環境変数を上書きすることが可能です。
成果物
実際に作成したチャットアプリは下記URLで公開しています。
DBはsupabaseを使っています。フリープランが500MBまでなので、定期的にDBリソースは削除予定です。
動作確認用のログイン情報は下記です。
| ユーザID | パスワード |
|---|---|
| tanaka | tanaka |
| yasukawa | yasukawa |
搭載した機能
下記機能を実装しています。
- 認証機能
- チャット機能(DenoのBroadCastAPIを活用し、双方向通信でリアルタイム性のあるチャットを実装)
- ユーザ作成機能
- トークルーム作成機能
- トークの既読機能
このチャットアプリは今後も改善し、バージョンアップしていこうかなと思います。deno+Freshに慣れるためには適したテーマ・題材だったなあと思います。
行き詰まった時には・・・
Deno+Freshを使ったプロダクトがまだ少ないので、行き詰まった時に参考になる情報がインターネット上に少ないです。Deno本家のWEBサイトは最近Deno+Freshで書き直されたようでして、GitHubにもソースコードが公開されているので、教科書代わりにしています。
参考にした記事
以下の記事を参考にさせていただきました。大変よくまとまっています。
Discussion