💈

CDN の Google Font を参照している CSS を改変せずに、ローカルのアセットを使うよう仕向ける

に公開

背景

HTML/CSS の話です。

とある既製の UI ライブラリが、その提供する CSS ファイル内で、Google Font の CDN エントリを import していました。

@import"https://fonts.googleapis.com/icon?family=Material+Icons";

さてその UI ライブラリを使用して構築されたとある Web アプリケーションが、インターネットに到達できない閉じたイントラネット内でのみ使用されることになりました。そうなると、前述の Google Font の CDN 参照はエラーとなって利用できません。代わりに、その Web アプリケーションローカルの静的アセットとして、Google Font を配置し利用するようにすれば、ひとまずアプリケーションの表示上は、ちゃんと指定のフォントで表示されるようになり解決します。UI フレームワークの CSS 内に記述された CDN 参照している import の箇所で、目的の CSS が読み込めないエラーが起きるでしょうけれども、とりあえず放置でいいだろう、と考えました。

しかし実際に試したところ、詳細割愛しますが、諸々のネットワーク環境の妙で、Google Font の CDN を import しているところで永遠の読み込み待ちになってしまい、アプリケーションが起動しなくなってしまいました。

じゃぁその UI フレームワークの CSS をちょっといじって、import 文の行を削除してしまえばいい話なのですが、しかしその UI フレームワークのバージョンアップに追随するたびに毎回、その UI フレームワークの CSS にパッチを忘れずに適用する必要が発生するため、それはそれで嬉しくないなぁ、と思案してしまいました。

Service Worker で解決してみる

そこで今回は、Service Worker を使って解決することにしました。つまり、Web ブラウザが備える機能のひとつである Service Worker は、ブラウザ内で当該オリジンで動作する、自分の書いた JavaScript コードで動作するネットワークプロクシとして振る舞えます。これを利用して、Google Font の CDN へのネットワークアクセスを、自前で実装した Service Worker プログラムで捕捉し、応答を空の文字列に書き換えてしまおう、という作戦です。これならば、HTTP GET すると永遠の待機になってしまう Google Font CDN へのアクセスを回避することができます。

Web アプリケーションローカルの静的アセットとして、Google Font は組み込み済みの前提で、以下のように Service Worker プログラムを記述します。

service-wroker.js
// ブラウザからのネットワークアクセスを捕捉
self.addEventListener('activate', event => event.waitUntil(self.clients.claim()));
self.addEventListener('fetch', event => event.respondWith(onFetch(event)));

const onFetch = async (event) => {
  const { request: { method, url } } = event;
  // Google Fonts CDN への GET 要求は横取りし...
  if (method === 'GET' && url === 'https://fonts.googleapis.com/icon?family=Material+Icons') {
    // 空のテキストを返す
    return new Response('/* empty */', { headers: { 'Content-Type': 'text/css' } });
  }

  // それ以外の要求はブラウザの既定の実装に任せる
  return await fetch(event.request);
}

このように実装した service-worker.js を、以下のようにページに組み込みます。

index.html
...
<script>navigator.serviceWorker.register('service-worker.js');</script>
</body>
</html>

これで、UI ライブラリ提供の CSS ファイルには手を出さずに、イントラネット内のみでの動作を実現することができました。

補足 - 他の方法

今回は、別途、ローカルの静的アセットとして配置した Google Font を読み込むように実装を追加しておきつつ、UI ライブラリから import している Google Font CDN の読み込みは、ただの空文字列に置き換えることで回避しましたが、他にも、以下のような実装も可能です。

ローカルの静的アセットを指す URL にリダイレクト

service-worker.js
self.addEventListener('activate', event => event.waitUntil(self.clients.claim()));
self.addEventListener('fetch', event => event.respondWith(onFetch(event)));

const onFetch = async (event) => {
  const { request: { method, url } } = event;

  if (method === 'GET' && url === 'https://fonts.googleapis.com/icon?family=Material+Icons') {
    const relativeUrl = './css/material-icons.css';
    const absoluteUrl = new URL(relativeUrl, location.href).href;

    // HTTP 301 Moved Permanently
    return Response.redirect(absoluteUrl, 301);
  }
  return await fetch(event.request);
}

ローカルの静的アセットを読み込んで応答として返す

service-worker.js
self.addEventListener('activate', event => event.waitUntil(self.clients.claim()));
self.addEventListener('fetch', event => event.respondWith(onFetch(event)));

const onFetch = async (event) => {
    const { request: { method, url } } = event;

    if (method === 'GET' && url.startsWith('https://fonts.googleapis.com/')) {

        const localPath = url
            .replace(/^https:\/\/fonts\.googleapis\.com\//, './css/')
            .replace(/\/icon\?family=Material\+Icons$/, '/material-icons.css');

        const response = await fetch(localPath);
        const headers = new Headers(response.headers);
        headers.set('Access-Control-Allow-Origin', '*');
        headers.set('Access-Control-Allow-Methods', 'GET');
        headers.set('Access-Control-Allow-Headers', 'Content-Type');
        headers.set('Access-Control-Max-Age', '3600');
        headers.set('Content-Type', 'text/css');
        return new Response(response.body, { headers });
    }
    return await fetch(event.request);
}}```

Discussion