mizdra's blog

ぽよぐらみんぐ

アセットの 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 ディレクトリ内の構造を変えて壊してしまうという出来事があった。

Twitter に投稿したツイートを Mastodon に転送するようにした

去年の 11 月から続く一連の騒動を受けて、id:mizdra のフォロワーの中でも Twitter から Fediverse に移行してきている人が増えてきた。僕自身は移行するつもりはないけれど、移行したフォロワーが僕のツイートを Fediverse から見れるように、ツイートを Mastodon へと転送するようにしてみた。せっかくなので、そのやり方について書き残しておく。

作戦

IFTTT という「〇〇したらXXする」みたいなピタゴラスイッチをボタンポチポチで作れるサービスがある。これを使い、当該 Twitter アカウントでツイートがされたら、それを契機に Mastodon にトゥートを投稿する、というピタゴラスイッチを組むことにする *1

転送する上での注意点 (2023/4/10 追記)

(トラバで情報を頂いたので追記)

今回紹介する方法では、普段は自動投稿のみをする BOT のようなものを作ることになる。しかし Mastodon インスタンスによっては、BOT の運用方法を規制するルールがあるそうだ。例えば、日本国内で著名な Mastodon インスタンスである mstdn.jp では、BOT からの Public なトゥートは禁止されている。mstdn.jp で今回紹介する方法を利用するなら、トゥートを Unlisted (ローカルタイムラインや連合タイムラインには表示せず、ホームタイムラインにのみ表示) にする必要がある。

bering.hatenadiary.com

インスタンスによってルールも異なるので、本記事で紹介している設定に加えて追加でいくつか設定が必要になるかもしれない。あるいは、そもそも自動転送やマルチポストといったものが禁止されている可能性もある。転送設定をする前にインスタンスのルールを確認するように。

ステップ1. Mastodon でアクセストークンを発行する

IFTTT から Mastodon にトゥートを投稿するには、Mastodon の「アクセストークン」と呼ばれるものが必要なので、まずはその作成から。

Mastodon のアクセストークンは、Mastodon のメニューの ユーザー設定 > 開発 > 新規アプリ から作成できる。

アクセストークンを発行するページの開き方の図解

するとアクセストークンの発行のために必要な情報の入力を求める画面が出てくる。今回は以下の内容を設定して「送信」ボタンを押せば良い。

  • アプリの名前: twitter2mastodon など
    • 発行したアクセストークンが、どんな目的で使われているのかあとから区別できるよう、名前を付けておくための欄
    • 好きな名前を入れれば良い。id:mizdra は「twitter2mastodon」にした。
  • アプリのウェブサイト: デフォルトのままで OK
  • リダイレクトURI: デフォルトのままで OK
  • アクセス権: write:media, write:status だけチェックを入れて、他のチェックは全部外す
    • 発行されるアクセストークンを使ってできることをここで制限できる
    • デフォルトで read, write, follow にチェックが入っているが、今回は要らないので外す
    • 画像の投稿と、テキストの投稿ができるよう、write:media, write:status にチェックを入れる

「送信」ボタンを押すと「アプリが作成されました」、という表示が出てくるはず。アプリ名のところをクリックすると、生成されたアクセストークンを確認できる。

生成されたアクセストークンの確認方法

発行したアクセストークンは後のステップで使うので、どこかにメモしておく。ただしこのアクセストークンが他の人に漏れてしまうと、他の人が自分になりすましてトゥートできてしまうので、取り扱うには十分注意するように。

ステップ2. IFTTT のアカウントを作成する

https://ifttt.com/join から IFTTT のアカウントを作成する。流れでできるはずなので説明は割愛。もうアカウントを持っている人はスキップで OK。

ステップ3. IFTTT で「Twitter でツイートしたら Mastodon に転送する」Applet を作る

IFTTT では「Applet」というものを作って、ピタゴラスイッチのような自動化フローを組んでいく。今回もこの Applet を作る必要がある。IFTTT のページの右上にある「Create」ボタンから作成できる。

Applet の作成画面が出てくるので、以下のような手順で Applet を作っていく:

  1. 「If This」の横の「Add」ボタンをクリック
    1. 連携可能なサービス一覧が出てくるので、「Twitter」を探して選択
    2. Twitter のどのイベントを対象にピタゴラスイッチを起動させるか聞かれるので、「New tweet by you (新しくツイートをした時)」を選択
    3. 「Add new account」から自分の Twitter アカウントと IFTTT を連携
    4. 「Include」欄のうち、「retweets」にチェックを入れる
    5. 入力内容に間違いがないか確認して、「Create trigger」をクリック
      • 以下のようになっていれば OK
      • 「Twitter account」が設定されていて、「retweets」にチェックが入っていればOK
  2. 1 の画面に戻ってくるので、「Then that」の横の「Add」ボタンをクリック
    1. 連携可能なサービス一覧が出てくるので、「Webhooks」を探して選択
    2. 「Make a web request」を選択
    3. フォームが出てくるので以下の情報を入力
      • URL: https://<お使いの Mastodon インスタンスのドメイン>/api/v1/statuses
      • Method: POST
      • Content Type: application/x-www-form-urlencoded
      • Additional Headers: 空で OK
      • Body: access_token=<ステップ1でコピーしたアクセストークン>&status={{Text}}
    4. 入力内容に間違いがないか確認して、「Create action」をクリック
      • 画像のような内容になっていれば問題ない
  3. また 1 の画面に戻ってくるので、「Continue」をクリック
  4. Applet のタイトルを設定するよう指示されるので、Twitter => Mastodon などと入力
  5. 「Finish」を押して Applet 作成完了

これで Twitter でツイートしたら Mastodon に転送されるようになったはず。

ステップ4: Mastodon アカウントを BOT アカウントとしてマークする (オプション)

Twitter のツイートをトゥートする Mastodon アカウントは、いわば Bot である。そういう BOT のトゥートを見たくない人もいるので、その配慮として Mastodon アカウントを BOT としてマークしておくと良い (とブコメで教えていただいた。ありがとうございます。)。

BOT アカウントのマークは ユーザー設定 > プロフィール > 外観 にある 「これはBOTアカウントです」にチェックを入れればできる。

「これはBOTアカウントです」にチェックを入れればOK

あとお好みで ユーザー設定 > ユーザー設定 > その他 から「検索エンジンによるインデクスを拒否する」にチェックを入れたり、「投稿の公開範囲」を 未収載 に設定してみたりすると良いかも。Mastodon のアカウントの表示名を <ユーザ名>@twitter.com みたいにして、マルチポストだと分かりやすくするのも良いと思う。

ひとまずこれですべての設定が完了したはず。お疲れさまでした。

余談: id:mizdra が Mastodon に転送しようと思った背景

id:mizdra は情報収取ツールとして Twitter を利用している。Twitter さえ見ていれば欲しい情報が手に入るように、情報を発信する人を積極的にフォローしている *2。同じように情報収集目的で Twitter を利用している人は大勢居るはずで、おそらく @mizdra のフォロワーにも結構居るのではないかと思っている。そうした人が Fediverse に移行したとき、移行先の Fediverse でも情報収集のために、もともと Twitter でフォローしていた人を、Fediverse でフォローしたくなってくる。しかしご存知の通り、Twitter ユーザを Fediverse からフォローすることはできない。

これは Fediverse に移行する以上仕方のないことだと思う。ただ、同じ情報収取目的で SNS をやっている身としては、少し心が痛む。

id:mizdra が Twitter に投稿したツイートを Mastodon に転送するようにしたのは、少しでもそうした人の助けになればと思ったから。少なくとも僕が Twitter を情報収取目的で使っている以上、同じように情報収集目的で SNS をやっているフォロワーには不便させたくない。

*1:IFTTT の Twitter 連携が突然動かなったらこの方法機能しなくなってしまうけど、まあもしそうなったらその時なんとかするスタイルで。

*2:詳しくは: 個人的 Web フロントエンドスキルの獲得方法 - mizdra's blog

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

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