RAKSUL TechBlog

ラクスルグループのエンジニアが技術トピックを発信するブログです

HonoとDenoで社内ツールを作ってみた

こんにちは!ラクスルの灰原です! 軽量かつ高速な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行です。なかなかコンパクトに作ることができました。

goclocの実行結果

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の機能を真似たものです。

Fresh 1.2 – welcoming a full-time maintainer, sharing state between islands, limited npm support, and more

npm: specifireによるimportはもともとDenoの機能で、Denoでネイティブにnpmパッケージを扱うためのものです。 これを使うことで node_modules が生成されず、Denoのキャッシュにパッケージが保持されます。

npm: specifiers | Deno Docs

さて、これはDenoランタイム上の挙動、つまりサーバーサイドでの挙動です。 Freshではそれをブラウザ上で動かすフロントエンドのコードでも使えるようにしています。

esbuild_deno_loaderというesbuildのプラグインで、それが実現されています。esbuildによるバンドルをする際、npm: specifierの記述があれば、node_modulesではなくDenoのキャッシュを参照するようになっているようです。

feat: support for `npm:` specifiers by lucacasonato · Pull Request #67 · lucacasonato/esbuild_deno_loader · GitHub

この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などモダンな技術スタックを使った社内ツールについて紹介しました。 新しいフレームワークや言語をいきなりプロダクションで使うのは、安定性や社員の教育の面から難しい面も多々ありますが、社内ツールをはじめとした限定的なシーンからじわじわと使っていきたいものです。