blog.takurinton.dev

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

2022-09-26

こんにちは

どうも、僕です。 先日、ブログを新しくしました。=> ブログを SSG にした
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 を定義してしまうというもので、自分たち実装者が行うようなオーバーライドを防ぐことになります。内容はもともと自分が実装していたコードとほぼ同じです。

/**
* @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 が使えるようになるので、それ用の対応をしました。

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 について詳しくなれました。