🍋

FreshでWebComponentsをビルドするプラグインを作る

2024/01/03に公開

以前とりあえずFreshでWebComponentsを使えるようにしました。

https://zenn.dev/azulitenet/articles/using_webcomponents_with_fresh

結論としては未定義のWebComponentsはそのまま使えるので、VS Code上はエラーが出るものの型定義と <script> でJSファイルの読み込みさえ追加すれば動くよ!という内容です。

でも、Fresh使ってるのに毎度ビルドは面倒だし、自動でなんとかしたいよな……?
そういえば自分はTailwind使わないからオフにしてるけど、あれを個別に組み込めるということはビルド周りの機能追加がFreshに可能ということ。ならばそれを参考にWebComponentsを組み込めばよいのでは?

ということでWebComponentsのビルドを行うプラグインを作って、Freshに組み込んでみましょう。

Freshプラグイン

Freshにはプラグインを追加することができます。最も有名なのは、デフォルトで組み込まれて選択肢として出てくるTailwindでしょう。プロジェクト作成時にすべてYesにした場合はこのプラグインが組み込まれた状態になっています。

プラグインの詳細は以下です。

https://fresh.deno.dev/docs/concepts/plugins

こちらを見ると最終的に Plugin のオブジェクトを返せばよいのですが、ここのHookに buildStart というのもがあります。ここらへんに手を加えればなんとかできそうです。

import { Plugin } from '$fresh/server.ts';

export default function freshWebComponents(): Plugin {
  return {
    name: 'web_components',
    buildStart: async () => {
      console.log('web_components: buildStart');
    },
  };
}

だいたいこんな感じでプラグインは作れます。後はこのプラグインをFreshに追加してみます。

fresh.config.ts
import { defineConfig } from '$fresh/server.ts';
import freshWebComponents from 'プラグインのパス';

export default defineConfig({
  plugins: [ // 追加するプラグインの一覧
    freshWebComponents(), // ここを関数にして引数で設定をもらうみたいな作りが良さそう
  ],
});

こちらのサンプルコードにあるように今回は関数にしています。これはTailwindのプラグインもそのような形で第一引数経由でプラグインの設定を受け取っていました。オブジェクトをそのまま追加するより柔軟なのでこのやり方でいこうとおもいます。

これでひとまず準備ができたので試してみます。

deno task build
Task build deno run -A dev.ts build
The manifest has been generated for 3 routes and 1 islands.
web_components: buildStart
Assets written to: C:\XXXXXXXX\_fresh

無事 web_components: buildStart という文字列が出力されています。後はプラグイン内で頑張るだけです。

プラグインを作る

プラグインは色々実装すると結構大掛かりになるので今回はかなりシンプルな決め打ちスタイルでやっていきます。

下準備

今回はDenoの emit を使ってみます。これは内部で使われているトランスパイル用のプロジェクトらしいのである程度安心して使えると思います。(本当はFresh内部で使われているesbuildを拝借したかったものの、exportされているビルド系の関数がFresh用にカスタマイズされているので一旦シンプルなこちらを使うことにします。)

https://deno.land/x/emit

ここまで揃ったので必要な値を揃えていきます。

  • 出力
    • 今回はバンドルして1つのファイルにするので static/components.js に決め打ちする
  • 入力
    • 今回は対象となるWebComponentsのソースファイルを web-components/_components.ts に集める。

また挙動は以下のようにします。

  • deno task build した場合は static/components.js に出力する
  • deno task start した場合は /components.js が読み込まれた場合ミドルウェアでソースを返す
    • キャッシュがある場合はそのソースを返す
    • キャッシュがない場合はビルドしてキャッシュにソースを登録してから返す
    • WebComponentsのあるディレクトリ内のファイルを更新するとキャッシュを削除する
      • WebComponentsは性質上再登録ができないため、結局リロードが必要になる
      • ただしFreshが監視しているディレクトリ内に変更が生じた場合自動でリロードがはいる(今回はここで static/ へのファイル更新が発生しない作りなのでリロード必須。)

ではまずHTMLに /components.js を読み込むコードを追加しておきます。

routes/_app.tsx
      ~省略~
        <link rel='stylesheet' href='/styles.css' />
        <script src='/components.js'></script> // ここを追加
      </head>
      ~省略~

次にエントリーポイントを用意しておきます。

web-components/_components.ts
import {} from './sample-component.ts';

こちらでimportしている sample-component は以下です。

web-components/sample-component.ts
interface SampleComponent extends HTMLElement {
}

Promise.all([
  new Promise<string>((resolve, reject) => {
    const name = 'sample-component';
    if (customElements.get(name)) {
      // 定義済み
      return reject(new Error(`Defined: ${name}`));
    }
    if (document.readyState !== 'loading') {
      // DOMContentLoaded済みなので即初期化
      return resolve(name);
    }
    document.addEventListener('DOMContentLoaded', () => {
      // DOMContentLoaded時に初期化
      resolve(name);
    });
  }),
  Promise.resolve(<HTMLScriptElement> document.currentScript),
]).then((results) => {
  const [name] = results;
  customElements.define(
    name,
    // WebComponentsの実装では必ず implements しておく
    class extends HTMLElement implements SampleComponent {
      constructor() {
        super();

        const shadow = this.attachShadow({ mode: 'open' });

        const style = document.createElement('style');
        style.innerHTML = [
          ':host { background: gray; }',
        ].join('');

        const contents = document.createElement('div');
        contents.appendChild(document.createElement('slot'));

        shadow.appendChild(style);
        shadow.appendChild(contents);
      }
    },
  );
});

プラグイン内部の実装

次にプラグイン側の実装を行います。

import {
  Plugin,
  PluginMiddleware,
  ResolvedFreshConfig,
} from '$fresh/server.ts';
import { bundle } from 'https://deno.land/x/emit/mod.ts';
import * as path from '$std/path/mod.ts';

export function freshWebComponents(): Plugin {
  // 変更監視対象となるWebComponentsを含むディレクトリ
  const sourceDir = path.join(Deno.cwd(), 'web-components');
  // バンドルするエントリーポイント
  const entryPoint = path.join(sourceDir, '_components.ts');
  // キャッシュを返すファイルのパス
  const targetFilePathname = '/components.js';
  // バンドルしたファイルのパス
  const bundleFilePath = path.join(
    Deno.cwd(),
    'static',
    targetFilePathname,
  );
  // キャッシュ
  const cache = new Map<string, { content: string }>();

  const middleware: PluginMiddleware = {
    path: '/',
    middleware: {
      handler: async (_request, context) => {
        const pathname = context.url.pathname;

        // 今回対象のファイル以外はデフォルトのハンドラに任せる
        if (pathname !== targetFilePathname) {
          return context.next();
        }
        console.log('web_components: middleware handler');

        // キャッシュがあるかどうか確認
        let cached = cache.get(pathname);
        if (!cached) {
          // ない場合はビルドしてキャッシュに保存
          try {
            const { code } = await bundle(entryPoint);
            cached = { content: code };
            cache.set(pathname, cached);
          } catch (error) {
            console.error(error);
            return context.next();
          }
        }

        return new Response(cached!.content, {
          status: 200,
          headers: {
            'Content-Type': 'text/javascript',
            'Cache-Control': 'no-cache, no-store, max-age=0, must-revalidate',
          },
        });
      },
    },
  };

  const middlewares: Plugin['middlewares'] = [];

  return {
    name: 'web_components',
    configResolved: (freshConfig: ResolvedFreshConfig) => {
      console.log('web_components: configResolved');
      if (freshConfig.dev) {
        // 開発フラグが立っている場合はミドルウェアを追加
        middlewares.push(middleware);

        // WebComponentsのソースファイルを監視する
        setTimeout(async () => {
          const watcher = Deno.watchFs(sourceDir);
          for await (const _event of watcher) {
            // 対象ディレクトリ内のファイルが変更された場合はキャッシュを削除
            cache.delete(targetFilePathname);
          }
        });
      }
    },
    middlewares,
    buildStart: async (freshConfig: ResolvedFreshConfig) => {
      console.log('web_components: buildStart');
      console.log(freshConfig);
      //WebComponentsのビルド
      const { code } = await bundle(entryPoint);
      await Deno.writeTextFile(bundleFilePath, code);
    },
  };
}

ここで出てくるちょっとしたポイントは Deno.watchFs(sourceDir); でしょう。これは指定ディレクトリ内のファイルに更新が入った場合に検知できる監視となります。一応非同期処理なので setTimeout でくるんでます。
これを使えばWebComponentsを格納したディレクトリ内で更新が入った時にビルドができるので、Freshでサーバー起動時にコンポーネントを変更すると自動でビルドが走るところまでは追従できます。(WebComponentsは性質上リロード必須なので手動リロードはしないといけない。ただし監視対象である static/ に毎回出力すれば自動でリロードが走るので、生成物をちゃんと出力する場合はあまり考えなくても良い。)
自動ビルドに関しては後で触れるので、今回は監視対象ディレクトリ内のファイルが更新されたらキャッシュを削除する対応とします。

ここまで準備ができたら deno task build でビルドしてみます。すると static/components.js の出力を確認できます。

次にサーバーを起動しつつ実行したいですが確認のために static/components.js を削除しておきます。これで普通に起動した場合はJSが404になります。

削除できたら deno task start でサーバーを起動します。


ファイルは存在しないが正常にコードが返されている

WebComponentsも正常に動作しているようなので変更テストをやってみます。web-components/sample-component.ts の背景色を変更してみます。

web-components/sample-component.ts
        ~省略~
        style.innerHTML = [
          ':host { background: lightgray; }', // ここの色を変更
        ].join('');
        ~省略~

ターミナルのログには web_components: middleware handler とミドルウェアで処理が行われているのがわかりますが、リロードするまではWebComponentsに変化はありません。
リロードすると無事別の色に変化しているので問題はなさそうです。

エラーの解消

残るは前回も対応したエラーへの対処です。
色々やり方はあると思いますがものすごくざっくりとした対応ならばビルドするエントリーポイントとなるソースファイルにFreshで使う型定義を追加します。

web-components/_components.ts
// 型定義だけなのでビルドしても残らないがFresh側のエラー解消の手助けになる
import type { JSXInternal } from 'preact/src/jsx.d.ts';
// ビルドした時に中の実装をすべて取り込むために必要
import {} from './sample-component.ts';
// 型定義がある場合には個別にimportして下で使う
import type { SampleComponent } from './sample-component.ts';

declare global {
  namespace preact.createElement.JSX {
    interface IntrinsicElements {
      // 型定義。SampleComponentはHTMLElementにしておけばとりあえずエラーはなくなる
      ['sample-component']: JSXInternal.HTMLAttributes<SampleComponent>;
    }
  }
}

結構行儀が悪いので本来ならエントリーポイントと型情報を分離するとか、WebComponentsの実装に型定義を入れるとかやり方はあると思います。ただWebComponentsの再利用などを考えるとpreactやFreshで使うための型定義をWebComponentas側のソースファイルに入れておくというのも微妙なので、そこら辺を一箇所にまとめて解決する内容になっています。

しかし、これでエラーは解消できましたが多少面倒です。もう少し楽にしたいなと言う場合にはもうひと工夫必要になります。

自動ビルドに関して

今回は面倒なのでビルドとFreshのエラーの解消を同時に行うために、1ファイルにすべてを集めて無理やり解決しています。そのためだいぶ変な書き方になっています。
もしちゃんと自動ビルドをするなら以下のようにすべきでしょう。

  • 可能な限り無駄なビルドをさせない
    • バンドルされるファイルを管理し、適切にビルドする
    • ファイル数が少ないなら毎回ビルドしても良いが、少なくとも自動生成されるファイルは除外したい
  • 型定義とバンドルのためのエントリーポイントを分ける
    • Freshのエラー解消に必要なのは特殊な型定義で、ビルドに必要なのは依存関係

具体例を出すと以下のように分離すべきでしょう。

components.d.ts
// こちらはFreshのための型定義のみ
// 必要なのはimportするファイルパス、WebComponentsの型と名前
import type { JSXInternal } from 'preact/src/jsx.d.ts';
import type { SampleHeader } from './sample-header.ts';
import type { SampleComponent } from './sample-component.ts';

declare global {
  namespace preact.createElement.JSX {
    interface IntrinsicElements {
      ['sample-header']: JSXInternal.HTMLAttributes<SampleHeader>;
      ['sample-component']: JSXInternal.HTMLAttributes<SampleComponent>;
    }
  }
}
components.gen.ts
// こちらはバンドルするための依存関係のみ
// 必要なのはimportするファイルパス
import {} from './sample-header.ts';
import {} from './sample-component.ts';

このようになってくるとファイルの管理が必要になってきます。例えば以下のようなJSONを用意すれば2つのファイルを生成してからビルドすることも可能になります。

{
  "components": [
    {
      "file": "./sample-header.ts",
      "list": [
        {
          "name": "sample-header",
          "type": "SampleHeader"
        }
      ]
    },
    {
      "file": "./sample-component.ts",
      "list": [
        {
          "name": "sample-component",
          "type": "SampleComponent"
        }
      ]
    }
  ]
}

後は更新ファイルのうち必要なものが更新された場合依存関係からビルドをすればビルドが走りまくることもないかと思います。
特に ファイル名.gen.ts を自動生成してその後 ファイル名.js にビルドするというルールで作れば d.tsgen.ts で終わるファイルが更新された時は無視するというお手軽監視も実装できるでしょう。

とにかく面倒ではありますがある程度力技で実装できそうなところまで来ました。実際に上のようにJSONにWebComponentsの情報を登録すると自動ビルドするプラグインを作ってみました。

https://github.com/azulamb/fresh_web_components/

ファイル監視でざっくり500ms以内に再度イベントが来た場合はまとめていますが、これくらいしておくと不要にビルドが走らないかなと思います。また、こちらの場合は上と違ってファイル更新が入ると static/ にファイルを出力するので自動リロードが入ります。
やることが多いもののプラグインでディレクトリを監視して型定義を随時作っていればすぐにFresh側でもWebComponentsが使えるようになるので、そこそこFreshと同じ体験で開発できるかなと思います。

まとめ

Freshから提供されているTailwindプラグインを参考に、WebComponentsの自動ビルドを実装してみました。
状況に応じてサーバーのみで動くComponent、両方で動くIslandのComponent、そしてブラウザでのみ動くWebComponentsを同時に扱えるので個人的には満足しています。

ただ、例えば上で作ったプラグインは簡易版のためMinifyなどできないです。もう少し調査して可能ならFreshで使われているesbuildを拝借できればいいですが厳しい可能性もあるので、そこら辺は様子を見ながら移行できたらいいなと考えています。

何にせよ今回の実装で最低限WebComponentsをFreshに組み込む事ができたので、本格的に使っていく準備が整って嬉しいです。

Discussion