blog.takurinton.dev

wmr の prerender 時に明示的な fetch の定義が不要になる

2022-09-26

こんにちは

どうも、僕です。

先日、ブログを新しくしました。=> [ブログを SSG にした](/post/109)

wmr の prerender mode を使って SSG にしましたが、自分の実装したような wmr の prerender で `.md` ファイルを使ったブログを書くようなパターンだと、prerender 関数の中で fetch API をオーバーライドしてあげないとプロダクションビルドをする際に fetch API がない旨のエラーが吐かれてしまいます。

import { VNode } from “preact”;
let initialized = false;
export async function prerender(vnode: VNode) {
  if (!initialized) {
    initialized = true;
    const fs = (await eval(“u=>import(u)“)(“fs”)).promises;
    // fetch API を定義する、こうしないとエラーになる
    globalThis.fetch = async (url) => {
      const text = () =>
        fs.readFile(`dist/${String(url).replace(/^\//, “”)}`, “utf-8”);
      return { text, json: () => text().then(JSON.parse) };
    };
  }
  // ...
  // meta タグを取得したり静的ファイルを生成する処理が続く
}

これは Node.js v18 以上であれば問題ないと思いきや、ローカルのファイルを見に行くようなことは想定されてないのでバージョンに関わらずこれを書かないといけなくなります。

外部の cms や自前のサーバを持っている場合は Node.js v18 以上を使うことで以下のような記述はなくても良くなります。

今回はこれを `wmr@3.8.0` からは省略することができるようになるという話についてです。

wmr の魅力

wmr.dev

を見ると、設定が不要で TypeScript が使えて勝手に最適化してくれるという旨のことが書いてあります。

また、wmr はノーバンドルツールの先駆けであり、vite のドキュメントには

WMR by the Preact team provides a similar feature set, and Vite 2.0's support for Rollup’s plugin interface is inspired by it.

と記述されていて、vite も一部 wmr にインスパイアされてる部分があるようです。

https://vitejs.dev/guide/comparisons.html#wmr

つまり、設定が不要で色々ができるというのが魅力なのですが、prerender 時に自前で fetch を定義しないといけないとはなんとも理に適ってないと感じました。

改善策

2 つのプルリクエストに分けて実装を行いました。

globalThis.fetch の定義

上記の実装を不要にするために、まずはこれを解決するための fetch を定義しました。

これは、wmr の prerender のコアロジックにて、globalThis.fetch を定義してしまうというもので、自分たち実装者が行うようなオーバーライドを防ぐことになります。内容はもともと自分が実装していたコードとほぼ同じです。

  • Add globalThis.fetch
  • /**
    * @param {object} options
    * @property {string} [cwd = ‘.’]
    * @property {string} [out = ‘.cache’]
    * @property {string} publicPath
    * @property {any[]} customRoutes
    */
    export function prerender({
      cwd = “.”,
      out = “.cache”,
      publicPath,
      customRoutes,
    }) {
      // other logic...
    
      // @ts-ignore
      globalThis.fetch = async (url) => {
        const text = () =>
          fs.readFile(`${out}/${String(url).replace(/^\//, “”)}`, “utf-8”);
        return { text, json: () => text().then(JSON.parse) };
      };
    
      // other logic continue...
    }
    

    Node.js v18 で積まれる fetch API 想定した実装

    次に、そろそろ Node.js v18 のリリースでネイティブで fetch API が使えるようになるので、それ用の対応をしました。

  • Feat proposal enable fetching external resources using native fetch API in Node.js version 18 later
  • Node.js v18 以降で実装されている fetch API により、外部のリソースも取得することができるようになりますが、Node.js v17 以前ではその機能はありません。

    つまり、ここで考慮しなければいけないケースは、Node.js v18 以降の時と Node.js v17 以前の時、そしてそれぞれ外部リンクの時とローカルのファイルの時の 4 パターンになります。

    それぞれの対応は以下になります。

    | | 外部リンク | ローカルファイル |

    | ---------------- | ------------------------------ | -------------------------- |

    | Node.js v17 以前 | warning を出す | fs.readFile で取得しに行く |

    | Node.js v18 以降 | fetch API を使って取得しに行く | fs.readFile で取得しに行く |

    実際にレビューをもらった後のコードは以下のようになります。

    /**
    * @param {object} options
    * @property {string} [cwd = ‘.’]
    * @property {string} [out = ‘.cache’]
    * @property {string} publicPath
    * @property {any[]} customRoutes
    */
    export function prerender({
      cwd = “.”,
      out = “.cache”,
      publicPath,
      customRoutes,
    }) {
      // other logic...
    
      // ローカルのファイルを参照する際の関数
      // @ts-ignore
      async function localFetcher(url) {
        const text = () =>
          fs.readFile(`${out}/${String(url).replace(/^\//, “”)}`, “utf-8”);
        return { text, json: () => text().then(JSON.parse) };
      }
      const pattern = /^\//;
      // fetch が未定義の場合は Node.js v17 以前
      if (globalThis.fetch === undefined) {
        // @ts-ignore
        globalThis.fetch = async (url) => {
          // ローカルのファイルを参照していたら localFetcher 関数を使って取りに行く
          if (pattern.test(String(url))) {
            return localFetcher(url);
          }
          // 外部のリンクが渡されていたら警告を出す
          console.warn(
            `fetch is not defined in Node.js version under 17, please upgrade to Node.js version 18 later`
          );
        };
      } else {
        // globalThis.fetch に fetch の戻り値を入れると無限ループになるのでコピーしておく
        const fetcher = fetch;
        // @ts-ignore
        delete globalThis.fetch;
        // @ts-ignore
        globalThis.fetch = async (url, options) => {
          // ローカルのファイルを参照していたら localFetcher 関数を使って取りに行く
          if (pattern.test(String(url))) {
            return localFetcher(url);
          }
          // 外部リンクの場合はネイティブの fetch API を用いてリソースを取得する
          return fetcher(url, options);
        };
      }
    
      // other logic continue...
    }
    

    ここで注意しなければいけないのは、ユーザーは独自の fetch を定義することができなくなることです。

    つまり、上で自分が書いたような

    import { VNode } from “preact”;
    let initialized = false;
    export async function prerender(vnode: VNode) {
      if (!initialized) {
        initialized = true;
        const fs = (await eval(“u=>import(u)“)(“fs”)).promises;
        // fetch API を定義する、こうしないとエラーになる
        globalThis.fetch = async (url) => {
          const text = () =>
            fs.readFile(`dist/${String(url).replace(/^\//, “”)}`, “utf-8”);
          return { text, json: () => text().then(JSON.parse) };
        };
      }
      // ...
      // meta タグを取得したり静的ファイルを生成する処理が続く
    }
    

    というコードは強制的にオーバーライドされて意味をなさなくなります。

    ローカルのファイルを取得するだけではなく一手間加えたい場合や、node-fetch を使いたい!といった場合に対応できなくなりますが、一応回避策はあり、

    export async function prerender(vnode: VNode) {
        globalThis.funkyFetch = ... // do something
    }
    const fetcher = typeof window === ‘undefined’ ? funkyFetch : fetch;
    fetcher(...);
    

    のような形でユーザー側で別名定義をしてもらうなり実装をしてもらって回避することを想定しています。

    まとめ

    wmr でブログを作ってみたのでしばらく動向を追ったり貢献したりしていましたが、面白かったです。

    少しだけ module bundler や SSG について詳しくなれました。