丁寧なDeno+JSX

*1

サーバーレスFunctionsぐらいの気軽さでサーバーアリのWebアプリをデプロイしたいという時がある。主に自分たちだけが使うようなツール系のやつ。

その時に今までのようにSPA+APIアーキテクチャではなく、モノリシックなサーバーサイドアーキテクチャにしつつもフロントエンド開発と同じツールチェインを使いたい、と前から思っていた。

これは単にReactメタフレームワークでも一気通貫に時短で作れそうだけど、個人の楽しみのための活動なので、一旦世間のトレンドからは離れて自分が本当に必要だと思った要素技術のみを最小限に使って理解しながら試行錯誤したい。

※ただ第三者に提供するシステムとかは安全に作られた既存フレームワークに乗るのがいいというのもある

しばらく考えてみたところ、私にとっては「TypeScriptでJSXをテンプレートエンジンに使ってHTMLを書けるだけでよい」という所に落ち着いた

(一汁一菜でええんですわ的な*2 )

この用途に最適なのがDenoBunだと思っていて、今回はDenoを使ってみる。

Denoは.jsxおよび.tsxファイルを標準でサポートしており、特別なビルド構成などを用意しなくてよい。

本質的にシンプルであるというよりはDenoのコアに複雑さが隠されていて全部やってくれている

高レベルでは、Deno は TypeScript (TSX や JSX も同様に) JavaScript に変換します。これは Deno に組み込まれている TypeScript コンパイラ と、swc と呼ばれる Rust ライブラリの組み合わせによって行われます。型チェックされ変換されると、コードはキャッシュに保存され、ソースから再び JavaScript に変換することなく次の実行に備えることができます。 https://deno-ja.vercel.app/manual@main/typescript/overview

ランタイムにPreactを使う

JSXを解釈して最終的にHTMLを吐き出したいのでその設定をする。必要なのはReactのフル機能ではなくJSXのテキスト処理だけなのでPreactの方をランタイムとして使うことにした。

deno.jsonのcompilerOptionsにESMで指定する。

{
  "compilerOptions": {
    "jsx": "react-jsx",
    "jsxImportSource": "https://esm.sh/preact"
  }
}

ウェブサーバーからHTMLを出力するコード

import renderToString from "https://cdn.skypack.dev/preact-render-to-string@6.2.2?dts";

Deno.serve(
 (_req) => {
 return new Response(renderToString(<h1>Hello, World!</h1>), {headers: {"content-type": "text/html"}});
 }
);

import * from "https://... でバージョン番号ハードコードしてるのが落ち着かないみたいな問題はImport Mapsを使う。また@以下を省略してlatestを使える。

"imports": {
 "preact-render-to-string": "https://cdn.skypack.dev/preact-render-to-string@6.2.2?dts";
}

サーバーサイド実装もする

現時点ではJavaScriptがサーバーサイドのみで動くものとして扱うことができる。

import {Database} from "https://deno.land/x/sqlite3@0.9.1/mod.ts";
import renderToString from "https://cdn.skypack.dev/preact-render-to-string@6.2.2?dts";

type User = {
 id: number;
 name: string;
 email: string;
}

interface TopPageProps {
 users: User[];
}

export const TopPage = ({users}: TopPageProps) => (<html>
 <body>
 <h1>Users</h1>
 <ul>
 {users.map((user) => <li>{user.name}</li>)}
 </ul>
 </body>
 </html>
);

Deno.serve(
 (_req) => {
 const db = new Database("db.sqlite3");
 const users = db.prepare("SELECT * FROM Users").all<User>();
 return new Response(renderToString(<TopPage users={users} />), {headers: {"content-type": "text/html"}});
 }
);

最近はサーバーレスな環境でもLitestreamでSQLiteを使えばよいという考えなのでCloud RunFly.ioで動きそうなコンテナイメージを作ればいいと思っている。

こんな感じの定義からスタート

FROM denoland/deno

EXPOSE 8000

WORKDIR /app

ADD . /app

RUN deno cache main.ts

CMD ["deno", "run", "--watch", "--allow-net", "--allow-env", "--allow-read", "--allow-ffi", "--unstable", "main.ts"]

denodrivers/sqlite3を使うためにunstable FFI APIが必要になっている。

Tips(1): Tailwind CSSのクラスを記述して必要なstyleだけ埋め込みたい

TwindというCSS-in-JSがあってそれを使ってタグを書き換える。

docs.deno.com

従来のビルドで生成するのと比較すると実行時のオーバヘッドがある。

デプロイ

デプロイ先はDeno Deployと言いたいところだけど前述のとうりLitestreamを動かしたいのでCloud Runが無難。

Litestreamの運用もめんどくさいならFly.ioで1インスタンス固定にしてボリュームを付けておくだけでもいいのではないか。トランザクションは使えなさそうだけど。

最初からSQLiteではなくDeno KVをDBに活用するならDeno Deployでいいと思う。

この後は——

URLのルーティングやクライアントサイドのロジックが必要になってきた段階で無理せずHonoFreshに移動した方がいい。

Honoはプラットフォームごと移動できる余地もあるし(Cloudflare Workers等)、Freshはパスベースのルーティングもあるしクライアントサイドのハイドレーションも対応してる。

なら最初からNext.jsRemixでいいじゃん? という疑問については試して失敗してみないことには分かりませんな