こんにちは!ラクスルの灰原です! 軽量かつ高速なWebフレームワークであるHonoと、新進気鋭のJSランタイムであるDenoを使って、社内ツールを作ってみましたので紹介します。
作ったツール
テックブログ向けのアイキャッチ画像ジェネレータを作りました。
タイトルを入力して、 背景画像と文字色を選んで、 文字の位置と大きさを調整して、 後は「Download」ボタンを押せば画像が手に入ります。
これは以前、弊社デザイン組織で作られた「Zoom背景ジェネレータ」に多分に影響されています。 こちらのデザイナーブログも是非ご覧ください! note.com
技術スタック
このツールは利用頻度がそこまで多くないと思われることもあり、お手軽にローカルで動くアプリとして作ることにしました。 またプロダクションではなく社内ツールということもあり、モダンな技術を取り入れて作ってみました。 最終的な技術スタックは以下の通りです。
- Hono
- 軽量・高速で各種JSランタイムに対応したWebフレームワーク
- Deno
- 次世代JSランタイム
- esbuild
- Goで実装された高速なバンドラー
- Twind
- tailwind-in-js を実現するJSライブラリ
- Alpine.js
- 動きのあるページを手軽に作れるJSフレームワーク
Honoのコンセプトについて知りたい方は、作者のyusukebeさんのブログもご覧ください。 zenn.dev
ファイル構造はこのようになっています。
. ├── Dockerfile ├── README.md ├── public │ └── assets │ └── images # 背景画像 │ ├── ... │ ├── ... │ └── ... └── src ├── components # JSXコンポーネント │ ├── footer.tsx │ ├── header.tsx │ ├── layout.tsx │ └── main.tsx ├── scripts # ブラウザ上で動かすスクリプト │ ├── app.js │ └── canvas.js ├── index.tsx # アプリのエントリーポイント ├── twind.ts # twindを使うためのスクリプト └── esbuild.ts # esbuildを使うためのスクリプト
全体の行数カウントは429行です。なかなかコンパクトに作ることができました。
index.tsx
の内容
アプリのエントリーポイントである index.tsx
に関してもコンパクトに書くことができました。
index.tsx
ではHonoで配信する内容を設定して、最後に Deno.serve
をしています。各行を見ていきましょう。
まずはHono本体と関連のコンポーネントをimportします。
/** @jsx jsx */ import { Hono } from "https://deno.land/x/hono@v3.9.2/mod.ts"; import { jsx, logger, poweredBy, serveStatic, } from "https://deno.land/x/hono@v3.9.2/middleware.ts";
Twindやesbuildを使うためのスクリプトをimportします。これらはHonoのMiddlewareという形で実装されています。こちらについては後述します。 Middleware - Hono
import { tailwindStyleTagInjector } from "./twind.ts"; import { withForms } from "https://esm.sh/@twind/forms@0.1.4"; import presetTailwind from "https://esm.sh/@twind/preset-tailwind@1.1.4"; import { esbuildBundler } from "./esbuild.ts";
次に各種JSXコンポーネントをimportします。HonoはJSXを使うことができます! JSX - Hono
import { Layout } from "./components/layout.tsx"; import { Header } from "./components/header.tsx"; import { Footer } from "./components/footer.tsx"; import { Main } from "./components/main.tsx";
importが終わり、アプリの設定をしていきます。ここではアプリの初期化、各種Middlewareの設定をしています。
const app = new Hono(); app.use("*", logger(), poweredBy()); app.use("/public/assets/*", serveStatic({ root: "./" })); app.use( "*", tailwindStyleTagInjector({ presets: [ presetTailwind(), { theme: { preflight: withForms(), }, }, ], }) ); app.get( "/scripts/*", await esbuildBundler({ entryPoints: ["./src/scripts/app.js"], outdir: "/scripts/", }) );
最後に先ほどimportしたJSXコンポーネントを組み合わせて、ルートパスで返すHTMLを指定し、サーバを立ち上げます。
app.get("/", (c) => { return c.html( <Layout> <Header /> <Main /> <Footer /> </Layout> ); }); Deno.serve(app.fetch);
以上が index.tsx
の内容です。シンプルですよね!
Tips
HonoやDenoを使ってみて、いくつかTipsを得られたので紹介します。
HonoでTwindを使う
TwindをSSRで使う際、返却されるHTMLを読み込ませて、そこから適切なstyleタグが生成され、それを元のHTMLに埋め込んで配信するというような動きになります。 Use With: React • Twind.style
つまり、Honoがレスポンスを返す直前で処理をフックして、Twindを使ってレスポンスのbodyを書き換える必要があります。 これはHonoのMifflewareを作ることで実現しました。
HonoのContextからbodyの内容を取得し、Twindの inline
メソッドでstyleタグを挿入済みのHTMLを生成しています。
twind.ts
import { createMiddleware } from "https://deno.land/x/hono@v3.9.2/helper.ts"; import { inline, install } from "https://esm.sh/@twind/core@1.1.3"; // response bodyにTailwindを使うために必要なstyleタグを挿入する // see: https://twind.style/packages/@twind/core#inline export const tailwindStyleTagInjector = (config: any) => { install(config); return createMiddleware(async (c, next) => { await next(); if (!c.res.body) { return; } const stream = c.res.body.pipeThrough(new TextDecoderStream()); const buffer: string[] = []; for await (const chunk of stream) { buffer.push(chunk); } const html = buffer.join(); const inserted_html = inline(html); c.res = new Response(inserted_html, c.res); }); };
HonoのJSXでAlpine.jsを使う
HonoのJSXの中で、Alpine.jsを使った :src="thumbnail"
のような動的なアトリビュートの割り当てを記述すると、下記のようなエラーになってしまいます。
error: The module's source code could not be parsed: Unexpected token `button`. Expected jsx identifier at xxx
これはHonoのJSXとhtmlヘルパーを組み合わせることで解決できます。以下のようにJSXのなかにhtmlヘルパーの記述を埋め込むことで、エラーにならずにレンダリングすることができます。
const Component = () => ( <button> {html` <img :src="thumbnail" /> `} </button> );
今回Honoを使ってみて、このJSXとhtmlヘルパーの組み合わせがとても体験が良いと感じました。HonoのJSXについてはyusukebeさんのこちらのブログでも紹介されています。 zenn.dev
Hono+Denoでesbuildを使う
このアプリはブラウザ上で動くスクリプトがAlpine.jsに依存していますが、実はCDNもNodeも使っていません。
実際のスクリプトをご覧ください。そうです、Alpine.jsのインポートにDenoのnpm:
specifierを使っています!
import Alpine from "npm:alpinejs"; import { setupFunctions } from "./canvas.js"; window.Alpine = Alpine; Alpine.start(); setupFunctions();
これはDeno公式のWebフレームワークであるFreshの機能を真似たものです。
npm:
specifireによるimportはもともとDenoの機能で、Denoでネイティブにnpmパッケージを扱うためのものです。
これを使うことで node_modules
が生成されず、Denoのキャッシュにパッケージが保持されます。
さて、これはDenoランタイム上の挙動、つまりサーバーサイドでの挙動です。 Freshではそれをブラウザ上で動かすフロントエンドのコードでも使えるようにしています。
esbuild_deno_loaderというesbuildのプラグインで、それが実現されています。esbuildによるバンドルをする際、npm:
specifierの記述があれば、node_modules
ではなくDenoのキャッシュを参照するようになっているようです。
このesbuild_deno_loaderとesbuildを使ったバンドルを、HonoのMiddlewareとして実装することで、npm:
specifierで依存先を記述したフロントエンドのコードを動かしています。
esbuild.ts
import { createMiddleware } from "https://deno.land/x/hono@v3.9.2/helper.ts"; import * as esbuild from "https://deno.land/x/esbuild@v0.19.2/mod.js"; import { denoPlugins } from "https://deno.land/x/esbuild_deno_loader@0.8.2/mod.ts"; export type EsbuildBundlerOptions = { entryPoints: string[]; outdir: string; }; export const esbuildBundler = async (options: EsbuildBundlerOptions) => { const bundle = await esbuild.build({ plugins: [...denoPlugins()], entryPoints: options.entryPoints, outdir: options.outdir, bundle: true, format: "esm", write: false, }); esbuild.stop(); return createMiddleware(async (c, next) => { const url = new URL(c.req.url); const output = bundle.outputFiles.find((v) => v.path == url.pathname); if (!output) { await next(); return; } c.res = c.body(output.text); }); };
おわりに
HonoやDenoなどモダンな技術スタックを使った社内ツールについて紹介しました。 新しいフレームワークや言語をいきなりプロダクションで使うのは、安定性や社員の教育の面から難しい面も多々ありますが、社内ツールをはじめとした限定的なシーンからじわじわと使っていきたいものです。