mizdra's blog

ぽよぐらみんぐ

Next.js で言語ごとに異なるアセット画像を埋め込む

多言語対応している Web アプリで、あらかじめファイルに書き出しておいた画像 (アセット) が言語別にあって、それをページに埋め込むにはどうするか、という話題です。基本は言語ごとに別々の画像を出すことないと思いますが、たまーにあるんですよね。例えばGoogle Play のバッジが言語ごとに違うので、これを言語ごとに出し分けたいとか。

色々方法があるので、それをいくつか紹介します。

方法1. import 文を言語の数だけ書く

シンプルにやるなら、言語の数だけ import 文を書いて、next/image<Image> コンポーネントで読み込むコードを書くかと思います。

// pages/index.ts
import Image from 'next/image';
import GooglePlayJaJP from '../assets/ja-JP/google-play.png';
import GooglePlayEnUS from '../assets/en-US/google-play.png';
import GooglePlayZhCN from '../assets/zh-CN/google-play.png';
import GooglePlayDeDE from '../assets/de-DE/google-play.png';
import GooglePlayItIT from '../assets/it-IT/google-play.png';
// ...

const GooglePlayMap = {
  'ja-JP': GooglePlayJaJP,
  'en-US': GooglePlayEnUS,
  'zh-CN': GooglePlayZhCN,
  'de-DE': GooglePlayDeDE,
  'it-IT': GooglePlayItIT,
  // ...
} as const

export function TopPage() {
  const lang = useLang();
  return (
    <div>
      <Image src={GooglePlayMap[lang]} alt="Google Play" />
    </div>
  );
}

Next.js way に沿っている一方で、言語の数に比例してコード量が増えるのがイマイチですね。

余談: import 文で読み込むと cache-busting パターン対応をやってくれる

import 文を使って画像を import すると、フレームワークのビルドツールが GooglePlay 変数にアセットへのパスを割り当て、<Image src="/assets/google-play.c53d93.png" alt="Google Play /> というコードに変換します (c53d93は content hash)。加えてアセットも <ビルド成果物の出力先ディレクトリ>/google-play./logo.c53d93.png へとコピーします。要は cache-busting パターンに対応した変換をやってくれる訳です。

方法2. public ディレクトリと img タグを使う

Next.js には public ディレクトリという static なファイル置き場があります。例えば public/assets/ja-JP/google-play.png にアセットを置くと、/assets/ja-JP/google-play.png という URL で配信されます。

これを利用すると、言語ごとに異なる画像を全部 public ディレクトリに置き、それを img タグで参照する、というアプローチが取れます。

// pages/index.ts

export function TopPage() {
  const lang = useLang();
  return (
    <div>
      <img src={`/assets/${lang}/google-play.png`} alt="Google Play" />
    </div>
  );
}

ただこのように書くと、画像の URL が /assets/ja-JP/google-play.png, /assets/en-US/google-play.png, ... などに固定されてしまいます。これは HTTPキャッシュを使用して不要なネットワーク要求を防ぐ のようにアセットを Cache-Control: max-age=31536000 で長期キャッシュしている場合に問題になります。もしそのように長期キャッシュしている場合、画像が新しい内容に置き換わっても、古いキャッシュがブラウザから使われ続けてしまうため、ユーザからは古い画像が見えたままになってしまいます。

そのため、もしこのアプローチを採用するなら、古いキャッシュが使われないよう工夫しなければいけません。例えば Cache-Control: no-cache で毎回キャッシュが古くなっていないか検証するよう要求するとかですね。

あるいは (雑ですが) アセットの URL の末尾に git の commit hash をつけるとかでも良いと思います。デプロイする度に変わる値なので、デプロイするごとにキャッシュが無効になってしまいますが、まあそれは仕方ないということで。ただ、git の commit hash を環境変数から参照できるようにする仕組みの整備はちょっと面倒だと思います。

// pages/index.ts

export function TopPage() {
  const lang = useLang();
  return (
    <div>
      <img src={`/assets/${lang}/google-play.png?${process.env.GIT_COMMIT_HASH}`} alt="Google Play" />
    </div>
  );
}

方法3. import 文を自動生成する

やっぱり Next.js のお作法に則って import 文を使った方法で書きたい、cache-busting パターンにも自動で対応させたい、という人向けの方法です。import 文を自動生成する CLI ツールを作りましょう。

// scripts/generate-i18n-asset-map.ts

// 言語ごとに異なるアセットの map を生成するツール。

const assetPatterns = [
  // 言語ごとに異なるアセットが増えたら、ここに追加してください。
  // プロジェクトのルートディレクトリからの相対パスで書くこと。
  'assets/[lang]/google-play.png',
]
const langs = ['ja-JP', 'en-US', 'zh-CN', 'de-DE', 'it-IT']

import {parse, resolve} from 'path'
import {mkdir, rm, writeFile} from 'fs/promises'

import {pascalCase} from 'change-case'


const rootDir = resolve(__dirname, '..')
const distDir = resolve(rootDir, '__generated__', 'i18n-asset-map')

async function generateI18nAssetMap(assetPattern: string) {
  // name は`'./assets/[lang]/google-play.png'` から `'google-play'` のみ取り出した文字列。
  const {name} = parse(assetPattern)
  const distFilename = resolve(distDir, name + '.ts')

  const importStatements = langs.map(lang => {
    const path = assetPattern.replace('[lang]', lang)
    const identifier = pascalCase(name + lang)
    return `import ${identifier} from '../../${path}'`
  })
  const importMapProps = langs.map(lang => {
    const key = lang
    const value = pascalCase(name + lang)
    return `  '${key}': ${value},`
  })
  const importMapName = pascalCase(name + 'Map')
  const content = `
${importStatements.join('\n')}

const ${importMapName} = {
${importMapProps.join('\n')}
} as const
export default ${importMapName}
  `.trim()

  await writeFile(distFilename, content)
}

// map ファイルの出力先は `__generated__/i18n-asset-map/<拡張子を除いたファイル名>.ts` なので、
// ファイル名が同じ assetPattern が複数あると、同じ場所に出力されてしまう。
// それを防ぐために、assetPattern のファイル名が被っていないかチェックする。
function validateAssetPatterns() {
  const visitedNames = new Set<string>()
  for (const assetPattern of assetPatterns) {
    const {name} = parse(assetPattern)
    if (visitedNames.has(name)) {
      console.error(
        `${name} という名前のファイルの assetPattern が複数検知されました。ファイル名は assetPattern ごとに被らないようにしてください。`
      )
      process.exit(1)
    }
    visitedNames.add(name)
  }
}

async function main() {
  validateAssetPatterns()

  await rm(distDir, {recursive: true, force: true}) // 古いファイルを削除
  await mkdir(distDir, {recursive: true})
  for (const assetPattern of assetPatterns) {
    await generateI18nAssetMap(assetPattern)
  }
}

main().catch(console.error)

これを ts-node scripts/generate-i18n-asset-map.ts で実行すると、以下のようなファイルが生成されます。

// ui/__generated__/i18n-asset-map/google-play.ts
import GooglePlayjaJp from '../../assets/ja-JP/google-play.png'
import GooglePlayenUs from '../../assets/en-US/google-play.png'
import GooglePlayzhCn from '../../assets/zh-CN/google-play.png'
import GooglePlaydeDe from '../../assets/de-DE/google-play.png'
import GooglePlayitIt from '../../assets/it-IT/google-play.png'

const GooglePlayMap = {
  'ja-JP': GooglePlayjaJp,
  'en-US': GooglePlayenUs,
  'zh-CN': GooglePlayzhCn,
  'de-DE': GooglePlaydeDe,
  'it-IT': GooglePlayitIt,
} as const
export default GooglePlayMap

こういう感じで使えます。

// pages/index.ts
import Image from 'next/image';
import GooglePlayMap from '../__generated__/i18n-asset-map/google-play';

export function TopPage() {
  const lang = useLang();
  return (
    <div>
      <Image src={GooglePlayMap[lang]} alt="Google Play" />
    </div>
  );
}

<Image> がそのまま使えて、かつ cache-busting パターン対応もやってくれます。良いですね。

Relay の %future added value, %other との向き合い方

前提

  • まず議論の土台は https://github.com/facebook/relay/issues/2351
    • GraphQL では Enum に対する variant の追加が破壊的変更になりうる
      • 新しい variant が来ることを考慮してなくて、突然アプリケーションが壊れる可能性がある
      • 自分たちで実装している GraphQL API なら、variant の追加タイミングを調整できるので問題にならないようにもできる
      • しかし、外部の GraphQL API サーバーを利用している場合は、追加タイミングを予知できないので、ある日突然壊れる
    • そのため、Relay は未知の値が来ても壊れないよう実装することを推奨してる
      • それを開発者に意識させるために、relay-compiler が生成する Enum の型には'%future added value'という文字列が追加されてる
        • // from: https://github.com/facebook/relay/issues/2351#issue-300717245
          // ./src/events/__generated__/my_generated.graphql.js
          ...
          export type EVENT_TYPES = ('activity' | 'formation' | '%future added value');
          export type Event = {
            type: EVENT_TYPES
          }
          
      • このおかげで、未知の値を意識しながらコードを書くことになる
        • export const stringifyEvent = (eventType: EVENT_TYPES) => {
            if (eventType === 'activity') return 'アクティビティ'
            if (eventType === 'formation') return '編成'
            return ''
          
      • あるいは以下のように網羅性チェックをしても良い
        • この書き方だと、突然未知の値が来ても表示崩れが起きなくて、かつ tsc でコンパイルエラーにもできる
        • export const stringifyEvent = (eventType: EVENT_TYPES) => {
            if (eventType === 'activity') return 'アクティビティ'
            if (eventType === 'formation') return '編成'
            const _exhaustiveCheck: '%future added value' = eventType
            return ''
          }
          
    • とはいえいちいち未知の値のハンドリングをするのは面倒という議論もある
      • そもそも、GraphQL API を自分たちで作っているなら、variant の追加タイミングも制御可能なので、未知の値のハンドリングは適当でも良いはず
      • そうしたユースケースのために noFutureProofEnums オプションがある
      • noFutureProofEnums: trueを使うなら、以下のように書ける
        • export type EVENT_TYPES = ('activity' | 'formation');
          export type Event = {
            type: EVENT_TYPES
          }
          
        • export const stringifyEvent = (eventType: EVENT_TYPES) => {
            if (eventType === 'activity') return 'アクティビティ'
            if (eventType === 'formation') return '編成'
            return unreachable(eventType)
          }
          
      • 将来EVENT_TYPESが取りうる variant が増えたら、コンパイルエラーになる
        • export type EVENT_TYPES = ('activity' | 'formation' | 'notification');
          export type Event = {
            type: EVENT_TYPES
          }
          
        • export const stringifyEvent = (eventType: EVENT_TYPES) => {
            if (eventType === 'activity') return 'アクティビティ'
            if (eventType === 'formation') return '編成'
            return unreachable(eventType)
            //                 ^^^^^^^^^
            //                 Argument of type 'string' is not assignable to parameter of type 'never'.
          }
          

'%future added value' の仲間

Relay には '%future added value' 以外にも、'%other' という未知の値を表す文字列がある。前者が Enum の variant 向けであるのに対し、後者は interface の __typename 向けの文字列となってる。どちらも未知の値が来ても壊れないよう実装することを強制するために存在する文字列で、noFutureProofEnums オプションで生成を抑制できる。

君たちはどう '%future added value', '%other' と向き合うのか

以上を踏まえて、どう'%future added value', '%other' と向き合うべきか。

  • 3つ書き方を紹介したが、どれでも良い気はする
  • 改めて 3 つについて要点を抑えながら取り上げ直すと...
  • 未知の値だったら シンプルにfallback するのが、最もオーソドックス
    • export type EVENT_TYPES = ('activity' | 'formation' | '%future added value');
      export const stringifyEvent = (eventType: EVENT_TYPES) => {
        if (eventType === 'activity') return 'アクティビティ'
        if (eventType === 'formation') return '編成'
        return ''
      }
      
  • 素朴に網羅性チェックをしたいなら以下
    • export const stringifyEvent = (eventType: EVENT_TYPES) => {
        if (eventType === 'activity') return 'アクティビティ'
        if (eventType === 'formation') return '編成'
        const _exhaustiveCheck: '%future added value' = eventType
        return ''
      }
      
    • variant が追加されたらコンパイルエラーにして、コードを変更するべきところを検出できるのは便利だが... variant を追加するのと一緒に、コンパイルエラーが出たところを修正していかないといけないのはちょっと大変
      • 同時にやるべき作業量が多い
      • まあでもこれはそういうものかも。歯を食いしばって修正していったらよい。
      • 面倒だったら// @ts-expect-error TODO: あとで直すとか書いておけば良い
  • GraphQL API を自分たちで作っていて、noFutureProofEnums オプションをオンにする覚悟があるなら、以下がオススメ
    • export type EVENT_TYPES = ('activity' | 'formation');
      export type Event = {
        type: EVENT_TYPES
      }
      
    • export const stringifyEvent = (eventType: EVENT_TYPES) => {
        if (eventType === 'activity') return 'アクティビティ'
        if (eventType === 'formation') return '編成'
        return unreachable(eventType)
      }
      
    • 未知の variant のハンドリングをしなくて良いのでシンプル
    • けど variant を追加した瞬間にアプリケーションが壊れる可能性があるので注意
      • ダウンタイムを可能な限り小さくするには、事前にフロントエンド側を改修して、新規の variant に対応したコードに更新しておくとか、PR を用意しておいて、variant が追加された直後にデプロイできるようにしておくとか、そういう運用が必要
      • バックエンドとフロントエンドをモノレポで運用しているなら、バックエンドで variant を追加する時に、フロントエンドも一緒に直してしまう
    • とはいえ多少のダウンタイムを許容できるならアリ
      • GraphQL API が variant を追加してから、フロントエンド側の改修が完了するまでの間、開発環境が壊れるのは許容する、とか
      • 本番環境にデプロイするまでには直ってるから OK、みたいな
    • そもそもスキーマ上 variant が追加されても、実際にその variant のデータが GraphQL API から返ってくるまでには、通常猶予がある
      • スキーマだけ変更しても、まだその variant を返す実装が入ってないとか
      • いきなりページが見れなくなるほどぶっ壊れることはそうないはず
  • あとこれはオプションだけど、素朴に if 使って書くのが面倒なら、ts-pattern 導入するとかが良いと思う

感想

3つ目の noFutureProofEnums オプションを ON にしつつ、網羅性チェックするのかかなり格好良いが、本当に noFutureProofEnums オプションを ON にして運用上問題が起きないのか、正直自信がない。

一般的な Relay 仕草から外れるからには、それ相応の自信を持ってやらないといけないが、その自信を形成するだけの判断材料が僕にはない。直感では大丈夫だと思うけど...実際どうかな...。「noFutureProofEnums オプション ON にしてやってるけど、全然困ってないぜ!」という人が居たら教えてください。

自分たちの管理下にない GraphQL API を参照しているアプリケーションを作っているのなら、1つ目か2つ目の書き方のどちらかを選択すると良いと思う。

アセットの import を簡単にする TypeScript Language Service Plugin を作った

Web ページを作るときに、あらかじめファイルに書き出しておいた画像 (アセット) をページに埋め込みたいことがよくあると思います。例えばヘッダーにサービスのロゴ画像を埋め込む場合、以下のようなコードを書くと思います。

// src/components/Header.tsx
export function Header() {
  return (
    <header>
      <img src="/assets/logo.png" alt="Logo image" />
      {/* ... */}
    </header>
  );
}

一方で、最近のWeb フロントエンドフレームワーク (例: Next.js, Remix) を使う場合は、import 文を用いて以下のように書くことが多いと思います。

// src/components/Header.tsx
import I_LOGO from '../assets/logo.png';
export function Header() {
  return (
    <header>
      <img src={I_LOGO} alt="Logo image" />
      {/* ... */}
    </header>
  );
}

このように書いてビルドすると、フレームワークのビルドツール *1I_LOGOにアセットへのパスを割り当て、<img src="/assets/logo.c53d93.png" alt="Logo image" />というコードに変換します (c53d93content hash)。加えてアセットも<ビルド成果物の出力先ディレクトリ>/assets/logo.c53d93.png へとコピーします。要は cache-busting パターンに対応した変換をやってくれる訳です。

面倒な cache-busting パターン対応を自動でやってくれる、ということで最近はこの方法でアセットを埋め込むのが主流になっていると思います。

アセットの import 文が書きづらい

前置きが長くなりましたが、いよいよ本題です。先程のコードを実際にエディタで書いてみると分かるのですが、アセットの import 文が書きづらいです。import 文が補完されないので、地道に書かないといけません。

youtu.be

.js/.ts ファイルの import の挙動と比較すると、アセットの import の体験がどれほど理想から遠ざかっているかがよくわかります。.js/.ts ファイルの import では、export されているアイテムの名前を打ち込むだけで、補完の候補にそれが出ます。加えて、候補に出たアイテムを選択すると import 文も自動挿入されます。

youtu.be

@mizdra/typescript-plugin-asset の紹介

この体験を何とか改善できないかと思い、作ったのが @mizdra/typescript-plugin-asset です。

github.com

これを使うと、.js/.ts の import と同等の体験を asset の import にもたらします。

youtu.be

使い方も簡単で、npm install -D @mizdra/typescript-plugin-asset してtsconfig.jsonにちょっとした設定を書くだけです。

// tsconfig.json
{
  "compilerOptions": {
    // ...
    "plugins": [
      {
        "name": "@mizdra/typescript-plugin-asset",
        // 補完に出したいアセットをグロブパターンで指定する
        "include": ["src/assets/**/*"],
        // 補完に出すアセットの拡張子を指定する
        "extensions": [".png", ".jpg", ".svg"],
      }
    ]
  }
}

ただ VSCode を使用している場合は、TypeScript Language Service Plugin が使えるように追加のセットアップが必要です。

  1. VSCode を開いて、コマンドパレットから「TypeScript: Select TypeScript Version」 を実行
  2. 表示されたメニューから「Use Workspace Version」を選択

使い方の説明は以上です。以降ではこのツールをどうやって作っていったのかについて解説していきます。

なぜアセットの import は .js/.ts ファイルの import のようにできないのか

最初に@mizdra/typescript-plugin-assetを作ろうと思った時点では、どうやったらアセットの import 文の自動挿入が実現できるかまだ分かっていませんでした。そこでまずは手がかりを掴もうと、アセットの import が .js/.ts ファイルの import のようにできない理由を探ることにしました。

調べてみると、どうやら JavaScript/TypeScript の Language Server がアセットを「プロジェクトを構成するファイルの1つ」と見なしていないのが原因らしいことが分かってきました。

(そもそも Language Server とは何かについてはこちらを参照):

JavaScript/TypeScript ファイル上で開発者がコード補完を要求した際に、どういう補完候補を出すのかは全て Language Server が決めています。そしてその補完候補を出すために、Language Server は「tsconfig.json の includeオプションやfilesオプションで指定されている .js/.ts ファイル *2」を解析して、export されている item を収集しています *3

よって、.js/.ts から export されているアイテムなら補完候補に出てきますが、それ以外のファイルは補完候補に出てきません。これがアセットの import が .js/.ts ファイルの import のようにできない理由です。

アセットでも import 文の自動挿入を実現する方法

import 文の自動挿入ができない原因が分かったので、その原因を何とかして回避できないか考えることにしました。幸いなことに、いくつかの回避方法があることが分かりました。

方法1: アセットを再 export する .ts ファイルを作る

.tsファイルから export されていれば補完候補に出てくる」という点に着目し、一度中間となる.tsファイルにアセットを import して、そこから再 export し、アセットの import 文の補完を実現する方法です。

// src/lib/assets.ts
import I_LOGO from '../assets/logo.png';
export { I_LOGO };

youtu.be

実際に import 元に書かれる import specifier (fromの後ろの文字列) は../assets/logo.pngではなく../lib/assetsになってしまうのですが...やりたいことは実現できています。

方法2: .d.ts ファイルを作成する

アセットが置かれているディレクトリに.d.tsファイルを併置すると *4、TypeScript の Language Server がアセットを「プロジェクトを構成するファイルの1つ」として扱ってくれます。これを利用しても、アセットの import 文の補完を実現できます。

// src/assets/logo.png.d.ts
// logo.png というモジュールの型定義ファイル。
// ここでは I_LOGO という string 型の変数を default export していると定義している。
declare const I_LOGO: string;
export default I_LOGO;

youtu.be

方法3: TypeScript Language Service Plugin を使う

TypeScript の Language Server には「TypeScript Language Service Plugin」という機能があります。これを使うと、ユーザレベルで Language Server の振る舞いをカスタマイズできます。具体的には補完候補に介入して本来の補完候補にはないものを挿入したり、あるいはトランスパイルを挟むことで .js/.ts 以外のファイルも扱えるよう拡張できます。

現に Vue.js で使われている TypeScript Vue Plugin は、.ts ファイルから .vue ファイルから export されているアイテムを補完候補に出したり、import 文を補完したりといった機能を提供してます。

youtu.be

アセットではなく .vue の import になってしまっていますが、原理上はアセットでも同等のことができるはずです。

@mizdra/typescript-plugin-asset でどれを採用するか

いずれも良い方法だと思うのですが、方法1, 2 は以下のような理由でイマイチかなーと感じました。

  • 方法1 は (難しすぎるので詳しくは解説しませんが) 未使用のアセットがビルド成果物に含まれてしまう可能性があるのが気になった
  • 方法2 は.d.tsを置くために生じるトラブルがあるのが気になった
    • PR の差分に含まれないよう.gitignoreしないといけなかったりとか
    • lint 対象に含まれないように.eslintignoreしないといけないとか
    • やれば良いことではあるけど、やらなくて良いに越したことはない

そこで @mizdra/typescript-plugin-assetでは、余計なファイルを作成せずとも機能し、扱いやすい方法3を採用することにしました。

OSS に上手く乗っかって楽をする

...といっても何となく実装が大変そうなイメージがあって、実装が億劫でした。そこで何とかして楽に作る方法がないか探ることにしました。

一縷の望みに賭けて TypeScript Vue Plugin のコードを読んでみると、コア部分が Volar.js という OSS に切り出されていることが分かりました。

volarjs.dev

Volar.js は組み込み言語の開発支援ツールを作るためのフレームワークです。組み込み言語というのが聞き慣れないですが、「ある言語の中に別の言語が埋め込まれているようなもの」のことです。例えば埋め込み言語の 1 つである .vue では、<script><style>で JavaScript/CSS といった言語を"埋め込む"ことができます。Volar.js はこうした組み込み言語の Language Server を簡単に作るための仕組みを、.vue に限らず任意の言語に向けて提供します。

この OSS を見て、ふと「アセットを埋め込み言語とみなせば良いのでは」というアイデアが思い浮かんできました。つまりアセットは TypeScript 言語を"埋め込む"言語とみなす訳です。

 // assets/logo.png
 <script setup lang="ts">
   declare const I_LOGO: string;
   export default I_LOGO;
 </script>
// assets/logo.png...
Text is not SVG - cannot display

アセットファイルが ts コードを埋め込む「埋め込み言語」のファイルとして扱う。

こうすれば Volar.js にうまく乗っかって 楽に Language Server を実装できそうということで、このアイデアを元に実装することにしました。

実装

ここまで来ればあとはやるだけです!

適当にアセット (.png, .svg など) は .ts を埋め込む言語であるというコード (VirtualFile class) を書いて...

それを Volar.js の API を使って、TypeScript Language Service Plugin の API と繋ぎ込むだけです。

Volar.js の世界観の知識がないと結構書くのが難しいですが... TypeScript Vue Plugin のコードを見ながら書けばなんとか...みたいな感じです。それさえ書いてしまえば、面倒なことは全部 Volar.js がやってくれます。全部で 400 行くらいなのでまあまあ手軽に実装できてると思います。

やり残していること

沢山あります!

今は exportedNamePrefix を一通りしか設定できないのですが、拡張子によって何通りかに変えたい場合もあると思っていて、そのための仕組みの実装を予定しています。が、まだそこまで手が伸びていないです。

コードの品質もイマイチで、殴り書きしてますし、TypeScript Language Service Plugin の API はよく分からずに触ってます。あと TypeScript Language Service Plugin や Volar.js との結合部分のテストコードはないです。

また、多分特定の環境によっては全然動かないとかがあると思ってます!世の中のエディタの環境は本当に多様なのですが、まだそういうケースを想定できていないです。

そういう感じなので、品質には期待しないでください!動かなければ Issue を送ってもらえると嬉しいですが、諸事情あって当面は開発リソースがないので対応は先送りになるかもしれません。

おわりに

この記事では、アセットの import を簡単にするためのツールの紹介、そしてそのツールをどうやって実装したのかについて解説しました。埋め込み言語の Language Server をイチから作ろうとするとかなり大変なのですが、Volar.js に乗っかり楽に作れたのがすごく良かったです。

余談ですが、Astro の Language Server も Volar.js を使って構築する作業が進んでいるようです。Vue.js コミュニティ発の OSS が、コミュニティを超えて影響を与えている、という点で素晴らしい出来事だと思います。

Volar.js 以外でも Vite/Vitest などの OSS も Vue.js コミュニティ発です。そして、そちらに至っては本当に多くの人に使われています。実は我々は Vue.js コミュニティの多大な尽力の上に立っている...ということを痛感させられますね。Vue.js コミュニティに感謝。

*1:正確には webpack や vite といった bundler が行っている作業です。非 JavaScript ファイルの import は ECMAScript 仕様では規定されておらず、bundler による拡張です。詳しくは https://webpack.js.org/guides/asset-modules/https://ja.vitejs.dev/guide/assets.html を読んでみてください。

*2:厳密には .cjs/.mjs/.cts/.mts/.tsx なども対象に含まれます。ただ例外を挙げるとキリがないので、ここでは省略してます。

*3:もちろんプロジェクトを構成するファイルに .js/.ts 以外を含めないかは Language Server の実装次第です。ただ、少なくとも JavaScript/TypeScript の Language Server の事実上の標準である tsserver は、.js/.ts 以外を含めない挙動になってます。VSCode で使われる JavaScript/TypeScript の Language Server は tsserver ですし、その他のエディタでもそれが使われることがほとんどです。

*4:--moduleResolution node では .d.ts ファイルを作成すれば良いですが、--moduleResolution node16 では --allowArbitraryExtensions オプションを有効化し、ファイル名も logo.d.png.ts にする必要があります。詳しくは https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-0.html#allowarbitraryextensions を参照してください。

Node.js でフィクスチャーファイルを簡単に作成するライブラリを作った

よく Node.js で CLI ツールを作っているのだけど、そのテストコードを書くのが大変だなあという課題感があった。例えば、ESLint のエラーをルールごとにグルーピングして、ルール単位で修正できるツール (eslint-interactive) を作ったときは、以下のようなテストコードを書いていた。

// fixtures/semi.js
const val = 0
// fixtures/prefer-const.js
let val = 0;
// fixtures/.eslintrc.js
module.exports = {
  root: true,
  parserOptions: { ecmaVersion: 2022 },
  rules: {
    'semi': 'error',
    'prefer-const': 'error',
  },
};
// src/core.test.ts
import { Core, takeRuleStatistics } from './core.js';
import { describe, it } from 'vitest';
import dedent from 'dedent';
import { join } from 'node:path';

const __dirname = new URL('.', import.meta.url).pathname;
const fixtureDir = join(__dirname, '../fixtures');

describe('Core', () => {
  const core = new Core({
    patterns: [fixtureDir],
  });
  const results = await core.lint();

  it('lint 結果の概要を出力できる', async () => {
    const formatted = await core.formatResultSummary(results);
    expect(formatted).toBe(dedent`
    ╔══════════════╤═══════╤═════════╤════════════╤═════════════════╗
    ║ Rule         │ Error │ Warning │ is fixable │ has suggestions ║
    ╟──────────────┼───────┼─────────┼────────────┼─────────────────╢
    ║ semi         │ 1     │ 0       │ 1          │ 0               ║
    ╟──────────────┼───────┼─────────┼────────────┼─────────────────╢
    ║ prefer-const │ 1     │ 0       │ 1          │ 0               ║
    ╚══════════════╧═══════╧═════════╧════════════╧═════════════════╝
  `);
  });
  test('rule ごとの lint 結果を返せる', async () => {
    const statistics = takeRuleStatistics(results);
    expect(statistics).toStrictEqual([
      expect.objectContaining({ ruleId: 'semi', errorCount: 1 }),
      expect.objectContaining({ ruleId: 'prefer-const', errorCount: 1 }),
    ]);
  });
});

eslint-interactive は *.js を ESLint で lint するということを内部的にやっているので、テストするには *.js (いわゆるフィクスチャーファイル) を事前に用意しておく必要がある。上記のテストコードでは、事前に fixtures ディレクトリに *.js を置いておく戦略を取っている。

fixtures ディレクトリの問題点

シンプルなアプローチなのだけど、個人的には以下のような問題点があると感じている。

  • テストコードを読むだけだとテストの挙動が分かりにくい
    • fixtures ディレクトリにはどんなファイルがあって、その内容はどうなっているかは、テストコードからは分からない
      • fixtures ディレクトリに見に行かないといけない
    • ファイルを何度も行き来してようやく挙動を理解できる...みたいな
  • テストケースごとに利用するフィクスチャーファイルを変更しづらい
    • こっちのテストケースでは fixtures/semi.js は要らないので除外したい...が手軽にできない
    • fixtures/test-case-1 のようにテストケースごとにディレクトリを切れば可能だが...
      • 逆にテストケース間でフィクスチャーファイルの共有が難しくなる

という訳で、これらの問題点を解決するために、@mizdra/inline-fixture-files というライブラリを作った。

@mizdra/inline-fixture-files とは

@mizdra/inline-fixture-files はフィクスチャーファイルをテストコードの中に埋め込むことができるライブラリである。

github.com

先程のテストコードを @mizdra/inline-fixture-files を使って書き換えると以下のようになる。

// src/core.test.ts
import { Core, takeRuleStatistics } from './core.js';
import { describe, it } from 'vitest';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import dedent from 'dedent';
import { createIFF } from '@mizdra/inline-fixture-files';

const fixtureDir = join(tmpdir(), 'inline-fs-fixtures', process.env['VITEST_POOL_ID']!);

await createIFF(
  {
    'semi.js': dedent`
      const val = 0
    `,
    'prefer-const.js': dedent`
      let val = 0;
    `,
    '.eslintrc.js': dedent`
      module.exports = {
        root: true,
        parserOptions: { ecmaVersion: 2022 },
        rules: {
          'semi': 'error',
          'prefer-const': 'error',
        },
      };
    `,
  },
  { rootDir: fixtureDir },
);

describe('Core', () => {
  const core = new Core({
    patterns: [createIFF.rootDir],
  });
  const results = await core.lint();

  it('lint 結果の概要を出力できる', async () => {
    const formatted = await core.formatResultSummary(results);
    expect(formatted).toBe(dedent`
    ╔══════════════╤═══════╤═════════╤════════════╤═════════════════╗
    ║ Rule         │ Error │ Warning │ is fixable │ has suggestions ║
    ╟──────────────┼───────┼─────────┼────────────┼─────────────────╢
    ║ semi         │ 1     │ 0       │ 1          │ 0               ║
    ╟──────────────┼───────┼─────────┼────────────┼─────────────────╢
    ║ prefer-const │ 1     │ 0       │ 1          │ 0               ║
    ╚══════════════╧═══════╧═════════╧════════════╧═════════════════╝
  `);
  });
  test('rule ごとの lint 結果を返せる', async () => {
    const statistics = takeRuleStatistics(results);
    expect(statistics).toStrictEqual([
      expect.objectContaining({ ruleId: 'semi', errorCount: 1 }),
      expect.objectContaining({ ruleId: 'prefer-const', errorCount: 1 }),
    ]);
  });
});

フィクスチャーファイルがインラインで書かれているので、ファイルを行ったり来たりせずとも、テストコードを読むだけでテストの挙動が分かるようになった。

フィクスチャーファイルのパスに型安全にアクセスする

createIFF の戻り値に paths というプロパティがある。これはフィクスチャーファイルの生のパスを保持するオブジェクトである。厳密に型付けされていて、フィクスチャーファイルの生のパスに型安全にアクセスできる。

const iff = await createIFF(
  {
    'semi.js': dedent`
      const val = 0
    `,
    'prefer-const.js': dedent`
      let val = 0;
    `,
  },
  { rootDir: fixtureDir },
);
iff.paths['semi.js']; // `join(fixtureDir, 'semi.js')` と同じ
iff.paths['prefer-const.js']; // `join(fixtureDir, 'prefer-const.js')` と同じ
iff.paths['not-exist.js']; // コンパイルエラー

rootDir をランダムに切り替える

@mizdra/inline-fixture-files をそのまま使っても便利だが、rootDir をランダムに切り替えるユーティリティを作ると、より便利になる。rootDir がランダムに切り替わるので、各テストケースを独立に保てる (あるテストケースでのフィクスチャーの変更が、他のテストケースに影響しない)。

// example/util/create-iff-by-random-root-dir.ts
import { randomUUID } from 'node:crypto';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createIFF, Directory } from '@mizdra/inline-fixture-files';

/**
 * NOTE: `fixtureDir` の肥大化を防ぐため、テストの実行毎に `fixtureDir` を削除する
 * コードを仕込んでおくと良い。
 * ```typescript
 * // vitest.setup.ts
 * import { rm } from 'node:fs/promises';
 * await rm(fixtureDir, { recursive: true, force: true });
 * ```
 */
const fixtureDir = join(tmpdir(), 'inline-fs-fixtures', process.env['VITEST_POOL_ID']!);

/** `rootDir` をランダムに切り替えつつ `createIFF` を呼び出すユーティリティ */
export async function createIFFByRandomRootDir<const T extends Directory>(directory: T) {
  const getRandomRootDir = () => join(fixtureDir, randomUUID());
  const iff = await createIFF(directory, { rootDir: getRandomRootDir() });
  return {
    ...iff,
    fork: async function forkImpl<const U extends Directory>(additionalDirectory: U) {
      const forkedIff = await iff.fork(additionalDirectory, { rootDir: getRandomRootDir() });
      return { ...forkedIff, fork: forkImpl };
    },
  };
// example/02-random-root-dir.test.ts
import dedent from 'dedent';
import { ESLint } from 'eslint';
import { expect, test } from 'vitest';
import { createIFFByRandomRootDir } from './util/create-iff-by-random-root-dir.js';

test('eslint reports lint errors', async () => {
  const iff = await createIFFByRandomRootDir({
    '.eslintrc.cjs': `module.exports = { root: true, rules: { semi: 'error' } };`,
    'src': {
      'semi.js': dedent`
        var withSemicolon = 1;
        var withoutSemicolon = 2
      `,
    },
  });

  const eslint = new ESLint({ cwd: iff.rootDir, useEslintrc: true });
  const results = await eslint.lintFiles([iff.paths['src/semi.js']]);
  const formatter = await eslint.loadFormatter('unix');
  const resultText = formatter.format(results);
  expect(resultText).toStrictEqual(dedent`
    ${iff.paths['src/semi.js']}:2:25: Missing semicolon. [Error/semi]

    1 problem
  `);
});

テストケース間でフィクスチャーファイルを共有する

iff.fork という、以前に作成されたフィクスチャファイルを引き継ぎながらルートディレクトリを変更する API がある。これにより、フィクスチャファイルをテストケース間で共有できる。

// example/03-share-fixtures-between-test-cases.test.ts
import { readFile } from 'node:fs/promises';
import dedent from 'dedent';
import { ESLint } from 'eslint';
import { describe, expect, it } from 'vitest';
import { createIFFByRandomRootDir } from './util/create-iff-by-random-root-dir.js';

describe('eslint', async () => {
  // `.eslintrc.cjs` をテストケース間で共有する
  const baseIFF = await createIFFByRandomRootDir({
    '.eslintrc.cjs': `module.exports = { root: true, rules: { semi: 'error' } };`,
  });

  it('reports lint errors', async () => {
    // fork` を使うと、`baseIFF` からフィクスチャを継承しながら、
    // フィクスチャの `rootDir` を変更できる。
    const iff = await baseIFF.fork({
      src: {
        'semi.js': dedent`
          var withSemicolon = 1;
          var withoutSemicolon = 2
        `,
      },
    });
    const eslint = new ESLint({ cwd: iff.rootDir, useEslintrc: true });
    const results = await eslint.lintFiles([iff.paths['src/semi.js']]);
    const formatter = await eslint.loadFormatter('unix');
    const resultText = formatter.format(results);
    expect(resultText).toStrictEqual(dedent`
      ${iff.paths['src/semi.js']}:2:25: Missing semicolon. [Error/semi]
  
      1 problem
    `);
  });
  it('fix lint errors', async () => {
    const iff = await baseIFF.fork({
      src: {
        'semi.js': dedent`
          var withoutSemicolon = 2
        `,
      },
    });
    const eslint = new ESLint({ cwd: iff.rootDir, useEslintrc: true, fix: true });
    const results = await eslint.lintFiles([iff.paths['src/semi.js']]);

    expect(await readFile(iff.paths['src/semi.js'], 'utf8')).toMatchInlineSnapshot('"var withoutSemicolon = 2"');
    await ESLint.outputFixes(results);
    expect(await readFile(iff.paths['src/semi.js'], 'utf8')).toMatchInlineSnapshot('"var withoutSemicolon = 2;"');
  });
});

内部的には「以前に作成されたフィクスチャファイルを引き継ぐ」ために、baseIFF からファイルをコピーしてきている。そのため fork する度にコピーのコストが発生する。ただ、baseIFF からのコピーは (システムが Copy-on-Write をサポートしていれば) Copy-on-Write で行われるので、多くの場合は無視できるレベルのコストだと思う。

まとめ

  • fixtures ディレクトリパターンはテストコードの可読性とメンテナンス性に課題がある
  • @mizdra/inline-fixture-files を使うと、フィクスチャーファイルをテストコードの中に埋め込める
  • 型安全にフィクスチャーファイルのパスにアクセスできたり、fork でフィクスチャーファイルをテストケース間で共有できたりと、便利な機能がある

どうぞお使いください。

github.com

Next.js で SSR を強制する

SSG も ISR も SSR も、どれも便利なのだけど、どれも扱いが難しい! SSR 1つを取っても、first paint の状態で GraphQL API から取得したデータがレンダリングされた状態を目指すなら、それなりに複雑な仕組みを getServerSideProps/_app.ts に実装しないといけない。

素朴にやると getServerSideProps には、GraphQL クライアントを初期化して、ページコンポーネントのレンダリングに必要なクエリを fetch して、それを pageProps に焼き込むためのコードがページごとに出現する訳で...

// 引用元: https://github.com/vercel/next.js/blob/e969d226999bb0fcb52ecc203b359f3715ff69bf/examples/with-apollo/pages/ssr.js#L20-L31
export async function getServerSideProps() {
  const apolloClient = initializeApollo()

  await apolloClient.query({
    query: ALL_POSTS_QUERY,
    variables: allPostsQueryVars,
  })

  return addApolloState(apolloClient, {
    props: {},
  })
}

流石に冗長なので、 Next.js の getServerSideProps を共通化する - mizdra's blog のようにラッパー (withApolloGSSP) を用意して、ページごとのコードの重複を減らしつつ、ページからは SSR 由来の複雑なロジックを意識せずに済むようにする、という方向に力が働いていくと思う。

// lib/get-server-side-props.ts
import { ApolloClient, NormalizedCacheObject } from '@apollo/client';
import { GetServerSideProps, GetServerSidePropsContext } from 'next';

// getServerSideProps の共通化とは関係ないので実装は省略し、型だけ定義してる
export declare function createApolloClient(
  cache: NormalizedCacheObject | undefined,
  tenantName: TenantName,
  context?: GetServerSidePropsContext,
): ApolloClient<NormalizedCacheObject>;

type WithApolloOuterContext = GetServerSidePropsContext;
type WithApolloInnerContext = WithApolloOuterContext & { apolloClient: ApolloClient<NormalizedCacheObject> };
type WithApolloInnerPageProps = {};
export type WithApolloOuterPageProps = WithApolloInnerPageProps & { initialApolloState: NormalizedCacheObject };

/**
 * Apollo クライアントを innerGSSP に渡しつつ、Apollo クライアントのキャッシュを pageProps に焼き込む HoF。
 * 焼きこまれたキャッシュは、_app.tsx で Apollo クライアントを初期化する際に使われる。
 */
function withApolloGSSP<P extends { [key: string]: any } = { [key: string]: any }>(
  innerGSSP: (context: WithApolloInnerContext) => ReturnType<GetServerSideProps<P & WithApolloInnerPageProps>>,
): (context: WithApolloOuterContext) => ReturnType<GetServerSideProps<P & WithApolloOuterPageProps>> {
  return async (context) => {
    const apolloClient = createApolloClient(undefined, context.tenantName, context);
    const innerResult = await innerGSSP({ ...context, apolloClient });
    if (!('props' in innerResult)) return innerResult;
    return {
      ...innerResult,
      props: {
        ...(await innerResult.props),
        initialApolloState: apolloClient.cache.extract(),
      },
    };
  };
}
// pages/_app.tsx
import '../styles/globals.css';
import type { AppProps } from 'next/app';
import { ApolloProvider } from '@apollo/client';
import { createApolloClient, WithApolloOuterPageProps } from '../lib/get-server-side-props';

type CustomAppProps = AppProps<Partial<WithApolloOuterPageProps>>;

export default function App({ Component, pageProps }: CustomAppProps) {
  const apolloClient = createApolloClient(pageProps.initialApolloState);
  return (
    <ApolloProvider client={apolloClient}>
      <Component {...pageProps} />
    </ApolloProvider>
  );
}
// pages/index.tsx
import { GetServerSideProps } from 'next';
import { withApolloGSSP } from '../lib/get-server-side-props';
import { gql, useQuery } from '@apollo/client';

const QUERY = gql`
  query TopPage {
    hello
  }
`;

export default function Home() {
  const { loading, error, data } = useQuery(QUERY);
  return (
    <main>
      <p>top page.</p>
      <div>
        {loading && <p>loading...</p>}
        {error && <p>error: {error.message}</p>}
        {data && <p>data: {JSON.stringify(data)}</p>}
      </div>
    </main>
  );
} 

// ページ側では `withApolloGSSP` で囲んで、クエリを fetch するだけで OK!
export const getServerSideProps = withApolloGSSP(async (context) => {
  await context.apolloClient.query({ query: QUERY });
  return { props: {} };
}) satisfies GetServerSideProps;

しかし、更にそこに SSG を導入しようとすると、今後は getStaticProps でも上記のような仕組みを再実装する必要がある! withApolloGSSPgetServerSideProps 専用なので、getStaticProps 用のものを別で用意しないといけない。また何種類か違うバリエーションのページ (例えば多言語対応をしたいとか) を作りたかったら、slug *1 (pages/[lang]/...) を導入して、バリエーションの違うページごとに URL が分かれる構成にしないといけない (SSR では同じ URL で異なるバリエーションのページを配信できるのに!)。

SSG は SSR の静的版、くらいの捉え方がよくされているのだけど、実際に両者を扱ってみると、想像以上に使い勝手が異なる。SSG を SSR と同じ気持ちで使っていると、「あれ? 何だか期待通りに動かないな」ということが頻発する。

SSR に一本化するという選択肢

...ということもあって、id:mizdra は SSG を避けて、極力 SSR を使うようにしている。メンタルモデルが違うものを同時に扱うのは難しいので、どっちかに一本化しようという考えがベース。サーバー負荷が気になるようであれば、CDN を導入してページをキャッシュすれば良い。CDN があれば、SSG/ISR はまず欲しくなることはない。

SSR 一本化を支援する仕組み

ページが常に SSR されるようにしたい訳だが、これが思いの外難しい。基本的には getServerSidePropspages/**/*.tsx に書いてあれば SSR されるのだが、もしそれを書き忘れた場合 (つまり getServerSideProps/getStaticProps/getInitialPtops いずれも書いてない場合)、Next.js はデフォルトで SSG をしてしまう。これは Next.js の Automatic Static Optimization という機能によるもの。

nextjs.org

開発途中など、getServerSideProps を書き忘れることはままあり、その度にうっかり SSG されてトラブル発生!という自体に繋がりかねない。厄介な挙動なので、id:mizdra はこれを回避するために getServerSideProps の書き忘れを検知する eslint rule を書いている。

// @ts-check

const {resolve} = require('path');

const nextConfig = require('../../next.config.js');
const {pageExtensions} = nextConfig;

const pagesDirPath = resolve(__dirname, '../../pages');

// _app と _document には getServerSideProps を書けないので無視する
const ignoredFileNames = [
  ...pageExtensions.map(ext => `${pagesDirPath}/_app.${ext}`),
  ...pageExtensions.map(ext => `${pagesDirPath}/_document.${ext}`),
];

/**
 * @file ページコンポーネントに `export const getServerSideProps = withGlobalGSSP(...)` が書かれていなければエラーを出す rule。
 */

/**
 * ファイル全体の AST から (`export const getServerSideProps = ...`) の node を探す
 * @param {import('estree').Program} programNode
 * @returns {import('estree').VariableDeclarator | undefined}
 * @example `export const getServerSideProps = ...` なら node が見つかる
 * @example `const getServerSideProps = ...; export { getServerSideProps };` なら node が見つからない
 */
function findVariableDeclaratorForGetServerSideProps(programNode) {
  // ファイルから getServerSideProps を探す
  for (const bodyNode of programNode.body) {
    if (bodyNode.type === 'ExportNamedDeclaration') {
      if (!bodyNode.declaration) continue;
      if (bodyNode.declaration.type !== 'VariableDeclaration') continue;

      // `export { getServerSideProps, otherExportedItem };` のような複数 export されているケースは考慮不要なので、[0] で取得
      const variableDeclarator = bodyNode.declaration.declarations[0];
      if (variableDeclarator === undefined) continue;

      if (variableDeclarator.id.type === 'Identifier' && variableDeclarator.id.name === 'getServerSideProps') {
        return variableDeclarator;
      }
    }
  }
}

/**
 * `export const getServerSideProps = ...` の右辺が `withGlobalGSSP(...)` となっているかどうかを返す。
 * @param {import('estree').VariableDeclarator} variableDeclarator
 * @returns {boolean}
 *
 * @example `export const getServerSideProps = withGlobalGSSP(async (context) => { ... })` なら `true`
 * @example `export const getServerSideProps = withGlobalGSSP(async (context) => { ... }) satisfies GetServerSideProps` なら `true`
 * @example `export const getServerSideProps = async (context) => { ... }` なら `false`
 * @example `export const getServerSideProps = withI18nGSSP(async (context) => { ... })` なら `false`
 * @example `export const getServerSideProps = withI18nGSSP(withGlobalGSSP(async (context) => { ... }))` なら `false`
 * @example `const getServerSideProps = withGlobalGSSP(async (context) => { ... }); export { getServerSideProps };` なら `false`
 */
function containsWithGlobalGSSP(variableDeclarator) {
  const init = variableDeclarator.init;
  if (!init) return false;

  // `... satisfies GetServerSideProps` と書かれていたら、そこから `...` の node を取り出す
  // @ts-expect-error -- TSSatisfiesExpression の型定義がなくてエラーになるので無視
  const callExpression = init.type === 'TSSatisfiesExpression' ? init.expression : init;

  if (callExpression.type !== 'CallExpression') return false;
  if (callExpression.callee.type !== 'Identifier') return false;
  if (callExpression.callee.name !== 'withGlobalGSSP') return false;
  return true;
}

/** @type {import('eslint').Rule.RuleModule} */
const requireGetServerSideProps = {
  create(context) {
    const filename = context.getFilename();
    if (!filename.startsWith(pagesDirPath)) return {};
    const isPageExtension = pageExtensions.some(ext => filename.endsWith(ext));
    if (!isPageExtension) return {};
    if (ignoredFileNames.includes(filename)) return {};

    return {
      Program(node) {
        const variableDeclaratorForGetServerSideProps = findVariableDeclaratorForGetServerSideProps(node);

        // getServerSideProps が見つからなかったらエラーを出す
        if (!variableDeclaratorForGetServerSideProps) {
          context.report({
            // ファイルの先頭に赤線を引いてエラーメッセージを表示
            loc: {line: 1, column: 0},
            message:
              'getServerSideProps がありません。必ず getServerSideProps を export してページを SSR してください。',
          });
          return;
        }

        if (!containsWithGlobalGSSP(variableDeclaratorForGetServerSideProps)) {
          context.report({
            node: variableDeclaratorForGetServerSideProps,
            message:
              'getServerSideProps が withGlobalGSSP でラップされていません。必ず getServerSideProps を withGlobalGSSP でラップしてください。',
          });
        }
      },
    };
  },
};

module.exports = requireGetServerSideProps;

適当に eslint-plugin-local-rules とかで読み込めば使えるはず。どうぞお使いください。

Relay でも msw の handler を型付きで書く

msw を使うと、GraphQL API をモックしてダミーレスポンスを返せます。以下のような handler と、その他少々のセットアップコードを用意するだけで簡単にモックできます。

// src/mocks/handlers.js
import { graphql } from 'msw';

export const handlers = [
  // ブログのトップページのクエリをモックする handler。
  // `graphql.query` の第一引数には、
  // `query <クエリ名> { ... }` の `<クエリ名>` の部分に対応した文字列を書く。
  graphql.query('BlogTopQuery', (req, res, ctx) => {
    return res(
      ctx.data({
        blog: { title: 'My Blog' },
        entries: [
          { id: '1', title: 'My First Entry' },
          { id: '2', title: 'My Second Entry' },
        ],
      }),
    );
  }),
];

graphql.query はクエリの response の型と variable の型の、2 つの型引数を取る API になってます。それぞれの型引数を渡すと、req.variables や handler の返り値に (静的) 型が付き、型に違反する値を返そうとしたらコンパイルエラーにできます。クエリの response/variable の型定義は、grahpql-codegen とその plugin (@graphql-codegen/typescript@graphql-codegen/typescript-operations) を使ってコード生成できます。

// src/mocks/handlers.js
import { BlogTopQuery, BlogTopQueryVariables } from '../gql/types';
import { graphql } from 'msw';

export const handlers = [
  graphql.query<
    BlogTopQuery,
    BlogTopQueryVariables,
  >('BlogTopQuery', (req, res, ctx) => {
    return res(
      ctx.data({
        blog: { title: 'My Blog' },
        entries: [
          { id: '1', title: 'My First Entry' },
          { id: '2', title: 'My Second Entry' },
        ],
      }),
    );
  }),
];
// codegen.ts
import type { CodegenConfig } from '@graphql-codegen/cli';

const config: CodegenConfig = {
  generates: {
  'src/gql/types.ts': {
    plugins: ['typescript', 'typescript-operations'],
      config: {
        flattenGeneratedTypes: true,
      },
  },
};
export default config;

他にも @graphql-codegen/typed-document-node@graphql-codegen/typescript-msw を使えばもう少し短く handler を書けます (詳しくは以下の記事を参照)。

tech.smartshopping.co.jp

the-guild.dev

本題: Relay でも msw の handler を型付きで書きたい

上記のような型付きの handler を、Relay を導入しているプロダクトでもやろうと思うと、実は一筋縄ではいきません。

というのも、Relay に備わっている @argumentsDefinition@refetchable といったディレクティブを、graphql-codegen が正しく処理できないためです。Relay にはいくつかの特別なディレクティブがあるのですが、このうちのいくつかが発行されるクエリやレスポンスの形式に影響を与える可能性があるのです。例えば fragment の定義の中に @refetchable ディレクティブを書くと、その fragment の単位でデータの再 fetch をする用のクエリを新たに定義できます (説明が難しいので詳しくは公式ドキュメントを読んでください)。

relay.dev

一方で graphql-codegen 自体は、そのままでは Relay 固有のディレクティブを正しく理解できません。そのため、Relay を導入しているプロダクトで msw の handler を型付きで書こうにも、クエリの response /variable の型定義がコード生成できず、どうにもらならない...という事態になってしまいます。

解決策: relay-compiler に型定義をコード生成させる

考えてみると当たり前なのですが、Relay の公式グッズなら Relay 向けに書かれたクエリを処理できるので、素直にそれを使えば良いです。relay-compiler という公式 CLI ツールがクエリの定義を読み取って TypeScript 向けのコード生成をしてくれるようになっていて、その中にクエリの response /variable の型定義 (XxxQuery$dataXxxQuery$variables) も含まれています。

ただ、Relay が生成するクエリの response の型定義は、デフォルトだと fragment masking 対応の特別な形式の型定義になっています。クエリが fragment で分割されていると、その fragment で分割されている分の field の型が、クエリの response の型定義に含まれていません。そのため、これをそのまま msw の graphql.query に渡しても、上手く機能しません。

そこで、@raw_response_type ディレクティブの出番です。これをクエリに対して付けると、従来の fragment masking 対応の response の型定義とは別に、fragment masking のない形式の型定義も生成してくれます。

// src/pages/index.tsx
import { graphql } from 'react-relay';

// クエリ名の後ろに `@raw_response_type` を付ける
const BlogTopQuery = graphql`
  query BlogTopQuery @raw_response_type {
    blog {
      ...BlogHeaderFragment
    }
    entries {
      ...EntryCardFragment
    }
  }
`;

export function BlogTopPage() {
  /* ... */
}
// src/mocks/handlers.js

import {
  // `XxxQuery$rawResponse` という名前で fragment masking のない形式の
  // 型定義が生成されているので、import してくる。
  BlogTopQuery$rawResponse,
  BlogTopQuery$variables,
} from '../src/__generated__/BlogTopQuery.graphql';
import { graphql } from 'msw';

export const handlers = [
  // handler に渡す
  graphql.query<
    BlogTopQuery$rawResponse,
    BlogTopQuery$variables,
  >('BlogTopQuery', (req, res, ctx) => {
    return res(
      ctx.data({
        blog: { title: 'My Blog' },
        entries: [
          { id: '1', title: 'My First Entry' },
          { id: '2', title: 'My Second Entry' },
        ],
      }),
    );
  }),
];

別解: @graphql-codegen/relay-operation-optimizer を使う

@graphql-codegen/relay-operation-optimizer というグッズがあって、これを使うと graphql-codegen でも Relay のディレクティブを処理できるようです。

the-guild.dev

しかし「List of Features」をよく見てみると、@argumentsDefinition@arguments には対応しているものの、@refetchable などその他ディレクティブには対応していないようです *1

あまり Relay の機能を駆使していないプロジェクトならこれでもやっていけるかもしれません (とはいえ素直に relay-compiler を使ったほうがトラブルに見舞われなくて良いと思います)。

あとがき

@raw_response_type ですが、API ドキュメントでは全く触れられておらず、用語集のページ *2 や mutation 関連のページ *3 でちょっと出てくるくらいで、全く公式ドキュメントに説明がなくて、ちょっと不安な感じがしますが... とはいえ id:mizdra の手元ではちゃんと動いてそうです。同じように Relay で msw の handler を型付きで書きたい人が居たら参考にしてみてください。

npm package を実装するための自分専用テンプレートリポジトリを作った

npm package を作る度にイチから開発環境の構築をしていて大変だったので、自分専用のテンプレートリポジトリを作りました *1

github.com

せっかくなので、テンプレートの特徴とか、どういうこと考えながら作ったとか紹介してみます。

はじめに: 基本的な技術スタック

  • npm
  • TypeScript
  • Node.js Native ESM
  • Prettier
  • ESLint
  • Vitest
  • Renovate
  • GitHub Actions
  • vscode 向けの各種設定ファイル (extensions.json, launch.json, settings.json)

GitHub の「テンプレートリポジトリ」機能を使う

GitHub にそれっぽい機能があったので使ってみました。

docs.github.com

「Use this template」というボタンが出て便利です。

「Use this template」ボタンから、テンプレートを利用したリポジトリを作成できる。

yarn/pnpm ではなく npm を使う

以前は yarn や pnpm も使ってましたが、npm だけでも十分だったので npm にしました。yarn/pnpm にあった機能は、以下のような代替機能に移行すれば良いかなと思ってます。

npm workspace を使っている人そこまで居ないはずなので、安定性が気になってましたが、happy-css-modules で試している限りはちゃんと動いてそうでした。細かいバグを踏み抜く可能性はあると思いますが、一般的な使い方をしている限りはそう困らないんじゃないかなと。

tsconfig.json は tsconfig/bases を使って書く

tsconfig.json の設定値は色々あって書くのかなり難しくて、id:mizdra も苦労してました。しかし最近は tsconfig/bases というコミュニティ管理の共有 config 集があるようで、良い感じだったのでこれに乗っかっることにしてみました。

github.com

いくつか config があるのですが、npm-package-template では @tsconfig/strictest@tsconfig/node18 の 2 つの config を使ってます。

// tsconfig.json
{
  "extends": ["@tsconfig/strictest/tsconfig.json", "@tsconfig/node18/tsconfig.json"],
  "exclude": ["node_modules", "dist", "bin"],
  "compilerOptions": {
    "module": "Node16",
    "moduleResolution": "node16",
    "noEmit": true,
    /* Lint Options */
    "noUnusedLocals": false, // Delegate to @typescript-eslint/no-unused-vars in eslint
    "noUnusedParameters": false, // Delegate to @typescript-eslint/no-unused-vars in eslint

    /* Debug Options */
    "locale": "ja"
  }
}

設定値もかなり妥当なものになっていて、ほぼそのまま受け入れれば十分でした。noUnusedLocalsnoUnusedParameters など lint 系の設定が ON になっていたので、id:mizdra はそれだけ OFF にしました (未使用変数の警告は eslint で検査するほうが細かい調整ができるので)。

コミュニティ標準に乗っかって楽ができて良いと思います。

*.ts だけでなく *.jstsc で型検査する

コードを *.ts で書いて tsc で型検査するのは当たり前ですが、npm-package-template では更に一歩踏み込んで、*.js の型検査もやってます。tsc には JSDoc の型アノテーションのコメントを書いて型付けされた *.js を型検査する機能があって、それを使ってます (@tsconfig/strictest を extends すると自動で ON になります)。

zenn.dev

*.ts の型アノテーションと違って、*.js の JSDoc の型アノテーションは書き方が独特です。TypeScript に慣れ親しんでいる人でも、書き方を調べながら書かないといけないくらいには、書き方が違います (id:mizdra も上記の id:qnighy さんの記事を見ながら書いているくらいなので...)。

難しいですが、id:mizdra としては型があったほうが嬉しいですし、書きまくっていればそのうち書き慣れるはずなので、まあ良いかなということで *.js の型検査をするようにしてみました。

自分用のテンプレートリポジトリだからこういう思い切った判断をしてますが、複数人で開発するようなリポジトリでこれを入れるのはガッツがないと難しいと思います。

tsconfig.jsoninclude オプションは省略する

tsconfig.jsoninclude オプションを使うと、そのオプションで指定されたパターンにマッチするファイルと、そこから辿れるファイルが tsc の型検査の対象となります。

{
  "include": ["src/*"],
  "compilerOptions": {
    "lib": ["es2019"]
    // ...
  }
}

つまり以下のようなディレクトリ構成があったとき...

my-app/
├─ asset/
│  ├─ locale.ts
├─ src/
│  ├─ util/
│  │  ├─ math.ts
│  │  ├─ fs.ts (実装途中のモジュールで、まだどこからも依存されていないという設定)
│  ├─ index.ts  (src/util/math.ts, asset/locale.ts に依存)
├─ package.json
├─ .eslintrc.js
├─ tsconfig.json (`"include": ["src/*"]` が指定されてる)

以下のファイルが tsc の型検査の対象になります。

  • src/index.ts ("include": ["src/*"] にマッチするため)
  • src/util/math.ts (src/index.ts から辿れるため)
  • asset/locale.ts (src/index.ts から辿れるため)

一方、以下のファイルは型検査の対象になりません。

  • src/util/fs.ts
  • .eslintrc.js

そのため、src/util/fs.ts などに型エラーがあっても CI をすり抜けてしまいます。tsserver (vscode に組み込まれている TypeScript の Language Server) も src/util/fs.ts をどの tsconfig.json を使って型検査すればよいか分からないので、エディタ上で実行される型検査もおかしなことになります。

"include: ["src/*"] と書く人は稀だと思いますが、(TypeScript 公式ドキュメントの include オプションの説明 にも書いてある) "include: ["src/**/*"] を書いている人は結構居るかもしれません。"include: ["src/**/*"] であれば src/util/fs.ts も型検査されるので、ほとんどの人はそれで支障ないと思います。ただ、"include: ["src/**/*"] でも .eslintrc.js は型検査されません。僕としてはありとあらゆるファイルを型検査して欲しいので、これでは困ります。

そこで npm-package-template では、include オプションを省略することにしました。省略すると "include: ["**/*"] 相当の挙動になり、.eslintrc.js も型検査対象に含まれるようになります。

{
  // 省略する
  // "include": ["src/*"],
  "compilerOptions": {
    "lib": ["es2019"]
    // ...
  }
}

余談ですが、モノレポで、npm workspace などを利用する場合、個々の workspace ごとに型検査に使用する tsconfig.json を変えたいことがあります (workspace ごとに lib を変えて型検査したいなど)。そういう場合は exclude オプションを併用したり、workspace ごとの tsconfig.json も併置する必要があります。この話をすると長くなるので、詳しくはまた別の機会に。

ビルドは tsc で行う

ビルドツールには tsc を使ってます。一般的な Web フロントエンドアプリケーションでは Webpack や Vite など、いわゆる bundler を使ってビルドしますが、別に npm package では bundle する理由はないので、bundler は使いません。*.ts => *.js のトランスパイルは tsc でできるので、それで十分です。

ビルド用の tsconfig.json は型検査用のものと別に用意する

ビルド時は noEmit を外したり、追加でいくつかのオプションを微調整したいことがあるので、型検査用のものとは別の tsconfig.json を用意してます。

シンプルに型検査用の tsconfig.json を extends しつつ、ビルド用向けにカスタマイズしているだけです。

{
  "extends": "./tsconfig.json",
  "include": ["src/**/*"],
  "compilerOptions": {
    "noEmit": false,
    "outDir": "./dist",
    "rootDir": "src", // To avoid inadvertently changing the directory structure under dist/.
    "sourceMap": true,
    "declaration": true,
    "declarationMap": true
  }
}

declarationMap はあまり知られてないオプションですが、めっちゃ便利なので付けておくと良いです。

rootDirdist ディレクトリ内の構造がうっかり変わって package が壊れないよう、保険として付けておくと良いです *2

Node.js Native ESM で書く

もう 2023 年だし全部 Node.js Native ESM で書けば良いでしょう、ということでコードは Node.js Native ESM で書くようにしました。本当は Dual Packages (CommonJS と ES Modules) に対応するのが望ましいと思いますが、そのための設定の手間や、Dual Package Hazard の回避のために、一旦 Pure ESM を前提としたテンプレートにしてます。やる気が出たら Dual Packages 対応するかも...。

関連:

quramy.medium.com

yosuke-furukawa.hatenablog.com

Prettier/ESLint/Renovate の設定は shareable config 化したものを使う

自分専用 shareable config を育ててるので、それを使ってます。

mizdra-style npm-scripts に従う

以前以下の記事で紹介した npm-scripts の書き方を npm-package-template でも採用してます。どんなプロジェクトでもこの書き方通りにやれば上手くいくので気に入ってます。

www.mizdra.net

{
  "scripts": {
    "build": "tsc -p tsconfig.build.json",
    "dev": "npm run build && node bin/example-command.js",
    "lint": "run-s -c lint:*",
    "lint:tsc": "tsc",
    "lint:eslint": "eslint .",
    "lint:prettier": "prettier --check .",
    "test": "vitest"
  },
  // ...
}

.xxxignore に書かれているファイル以外全て lint/format する

eslint .prettier --check . で lint/format します。eslint src/**prettier --check src/** とは書きません。何故なら lint 漏れが発生する可能性があるためです。加えて eslint や prettier の vscode 拡張は、原則として .eslintignore.prettierignore に書かれているファイル以外全て lint/format します。そのため、たとえ npm-scripts に eslint src/** と書いていても、それを無視して src/ ディレクトリ以外のファイルも lint/format してしまいます。

vscode の拡張機能とも lint/format 対象のファイルに違いが出てしまうので、eslint src/**prettier --check src/** と書くのは避けるのが良いです。

参考: ESLint, Prettier, VS Code, npm scripts の設定: 2021春

検査内容ごとに GitHub Actions の job を分ける

1 つの job で lint => test => build と順番にまとめてやると、どれか1つがコケた時点で、後続の検査が実行されなくなってしまいます。例えば lint でコケると、test や build が実行されません。仮に test や build がコケる状態あっても、その時点ではそれが発見されず、lint が通るよう直したあとに気づくという... あるあるだと思います。

これでは困るということで、npm-package-template では 検査内容ごとに job を分けることにしました。

「そんな調子で job の数を増やしていったら、GitHub Actions の同時並列実行数の上限に当たって困るんじゃない?」という意見もあるかもしれませんが、まあそうなったらその時考えれば良いかなと。そもそも今回は lint/test/build の 3 つしか job ないので大した量じゃないはず。

vscode 向けの設定ファイルを用意

id:mizdra は普段 vscode を使ってコーディングしているので、そのコーディングが快適にできるよう、いくつか設定ファイルを用意しました。

最後の .vscode/launch.json がイチオシで、これによって breakpoint を仕掛けながら npm package をデバッグできます。

breakpoint を仕掛けながらデバッグする様子。

たった 13 行の設定ファイルを用意するだけで動きます。手間の割に得られるものが大きくてオススメです。

// .vscode/launch.json
{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "dev",
      "type": "node",
      "request": "launch",
      "cwd": "${workspaceFolder}",
      "runtimeExecutable": "npm",
      "runtimeArgs": ["run", "dev"]
    }
  ]
}

テストランナーには Vitest を使う

今までずっと Jest を使ってきましたが、Node.js Native ESM のコードを Jest でテストしようと思うと、すごく複雑なセットアップが必要で、苦労してました。最近 Vitest を使ってみたところ、ほぼゼロコンフィグで Node.js Native ESM のコードのテストができて感動したので、Vitest に移行することにしました。

Vitest for VSCode」という vscode 上からテストを実行する拡張機能もあって、新興ライブラリながらエコシステムも整いつつあるように見えてます。まあでもよく触ってみると vscode-jest にはあるけど Vitest for VSCode にはない機能の存在に気づいたり。適時 Issue を立てたりとフィードバックしながら使うことになりそうです。

テストファイルはテスト対象ファイルの横に併置する (コロケーション)

src/math.ts のテストファイルを src/math.test.ts に置くという話です。要は以下の実践です。

www.mizdra.net

package.jsonfiles field を設定する

package 化したときに含まれるファイルを package.jsonfiles field で指定してます。

{
  // ...
  "files": [
    "bin",
    "src",
    "!src/**/*.test.ts",
    "!src/**/__snapshots__",
    "dist"
  ]
}

bindist だけあれば良いように思うかもしれませんが、*.ts を package 内に保持しておかないと tsconfig.jsondeclarationMap が機能しないので、src も必要です。ただ、src を丸ごと指定すると、コロケーションしているテストファイルも混じってしまうので、!src/**/*.test.ts!src/**/__snapshots__ も併記してます。

LICENSE や README も含める必要がありますが、それらは npm が自動で含めてくれるので、省略してます。

リリース方法をドキュメント化する

CONTRIBUTING.mdnpm publish コマンドなどを使ったリリース方法を書き留めることにしました。

npm publish のやり方がうろ覚えで、毎回調べていたので...。リポジトリに書いてしまえば間違いようがないはず。コードブロックにすることでコマンドのコピーボタンが表示されるようにしてるのがポイントです。

コードブロックにすることでコピーボタンが出現する。

.github/release.yml を用意する

GitHub のリリースノート自動生成機能を使うために必要なファイルです。

www.mizdra.net

npm-package-template では id:r7kamura さんの方法を真似て、Keep a Changelog の形式のリリースノートが生成できるよう設定してます。

r7kamura.com

あとがき

現代的なテンプレートリポジトリを作ることができて満足してます。あとコードを書きまくって得た知見をこうしてテンプレートリポジトリに集約できたのも良かったです。

テンプレートリポジトリ自体は CC0-1.0 で配布してるので、ご自由にお使いください。

*1:厳密にはリポジトリ自体は 4 年前からあったのだけど、ちゃんと整備してなくて滅んでいたので整備し直した、という背景。

*2:実際にこのオプションを付けてなかったために、とある package で dist ディレクトリ内の構造を変えて壊してしまうという出来事があった。

ポケットモンスター・ポケモン・Pokémon・は任天堂・クリーチャーズ・ゲームフリークの登録商標です.

当ブログは @mizdra 個人により運営されており, 株式会社ポケモン及びその関連会社とは一切関係ありません.