mizdra's blog

ぽよぐらみんぐ

JavaScript で GraphQL サーバーの技術選定をする際の登場人物

これは はてなエンジニア - Qiita Advent Calendar 2024 - Qiita 15日目の記事です。昨日は id:utgwkk さんの「ISUCONの感想戦を支えるEC2の自動開始・停止、そしてAWS Step Functions」でした。


はてなでフロントエンドエキスパートをしている id:mizdra です。この記事では、JavaScript で GraphQL サーバーの技術選定をする際に、どのようなツールやライブラリがあるのかを紹介します。

というのも、JavaScript で GraphQL サーバーを作ろうと思って検索してみると、「Next.js + Apollo Server + graphql-codegen で GraphQL サーバーを作ろう!」だとか、そういう記事が多数出てきます。ただ、複数のライブラリやツールを組み合わせると作れることは分かるのですが、どのライブラリ・ツールが何を担っていて、どういう代替ライブラリ・ツールに差し替え可能なのかが、正直調べても分かりづらいです。「Apollo Server」ってやつは「Pothos」ってやつに差し替えられるの? とか、そういった疑問が出てきがちです *1

そうした疑問に答えられるように、GraphQL サーバーを構成するライブラリやツールの整理をする、というのがこの記事の目的です。

はじめに要点だけ

  • GraphQL サーバーを作る際には、以下のようなレイヤーごとに技術選定すると良い
    • GraphQL Server
      • 「GraphQL クエリ」を受け取って、「GraphQL レスポンス」を返すことが仕事
      • スキーマ・Resolver は外で定義されたものを受け取って、それを使って GraphQL クエリを処理する
        • スキーマ・Resolver はユーザが好きなようなライブラリをツールを使って書ける
    • HTTP Server
      • 「GraphQL Server」には直接 HTTP リクエストを受け取って、HTTP レスポンスを返す機能がないので、そこを担う
    • スキーマ定義
    • Resolver
  • このレイヤーごとであれば、ライブラリやツールを差し替えできる
  • コードファーストでは、スキーマと Resolver が同時に定義される
    • Resolver の定義方法をどうするかを別個に検討しなくて良い
    • GraphQL Code Generator はなくてもやっていける

GraphQL Server

「GraphQL クエリ」を受け取って、「GraphQL レスポンス」を返すもののこと。有名どころだと以下のようなライブラリが存在する。

  • @apollo/server
    • Apollo Graph Inc. が作っている GraphQL Server ライブラリ *2
    • initial commit は 2016/4
    • この中ではもっとも有名で、使っている人も多い
    • 実装されている機能が豊富で、GraphQL のニッチな機能とかが、最も速く実装されている (Apollo Federation とか)
  • graphql-yoga
  • @hono/graphql-server
    • Hono が出しているやつ
    • @apollo/server や graphql-yoga に乗っからず、Hono のために専用のものを用意しているのは何故なんでしょうね

例えば @apollo/server は以下のようにして使います。ApolloServer というクラスに GraphQL スキーマと resolver を渡して初期化するだけです。

// https://www.apollographql.com/docs/apollo-server/getting-started より引用・一部改変
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';

const typeDefs = `
  type Book {
    title: String
    author: String
  }
  type Query {
    books: [Book]
  }
`;
const books = [
  { title: 'ゆゆ式 1', author: '三上小又' },
  { title: 'ゆゆ式 2', author: '三上小又' },
];
const resolvers = {
  Query: {
    books: () => books,
  },
};
const server = new ApolloServer({
  typeDefs,
  resolvers,
});

この辺は graphql-yoga も全く同じインターフェイスになってます

HTTP Server

@apollo/server や graphql-yoga は「GraphQL クエリを受け取って GraphQL レスポンスを返す」ことはできますが、直接 HTTP リクエストを受け取って、HTTP レスポンスを返したりといったことはできません。そのため、実際に機能する GraphQL サーバーを実装するには、HTTP Server / Router と組み合わせないといけません。

有名どころだと、以下のようなものが使われることが多いです。

スキーマ定義

さっき見たように、@apollo/server や graphql-yoga はスキーマを外から受け取るだけで、そのスキーマがどう定義されているかは関知しません。ここはユーザが自由に定義できます。

スキーマの定義方法は、以下のようなものがあります。

  • 文字列
    • 「GraphQL Server」で紹介したやり方
    • サンプルコードに使うくらいで、プロダクションではまず採用されない
  • スキーマファイル
    • schema.graphql などで定義するやつ
    • fs.readFile で読み込んで、ApolloServer に渡したら良い
  • コードファーストライブラリ

コードファーストライブラリの場合はちょっと特殊で、大抵スキーマと resolver を一緒に定義する作りになってます。

// https://pothos-graphql.dev/docs より引用
import { createYoga } from 'graphql-yoga';
import { createServer } from 'node:http';
import SchemaBuilder from '@pothos/core';
 
const builder = new SchemaBuilder({});
 
builder.queryType({
  fields: (t) => ({
    hello: t.string({
      args: {
        name: t.arg.string(),
      },
      resolve: (parent, { name }) => `hello, ${name || 'World'}`,
    }),
  }),
});
 
const yoga = createYoga({
  schema: builder.toSchema(),
});

builder.queryType の中でスキーマと Resolver を一緒に定義しているのが分かると思います。そしてそのスキーマと resolver の情報を builder.toSchema() で抽出して、yoga に渡しています。yoga の schema オプションは、typeDefs オプション・resolvers オプションに相当する情報をまとめて渡せるオプションで、これで一度にスキーマと Resolver の情報を yoga に渡しています。

Resolver

スキーマと同様、Resolver についても @apollo/server や graphql-yoga はそれが定義されているか関知しません。よってスキーマファーストで開発する場合、resolver をどうやって定義するかも決めなければなりません。こちらについてもアプローチが色々あります。

  • 手で頑張って書く
    • const resolvers = { ... } の中を気合で書く
    • 型チェックも何もない (本来 String しか返せない field に TypeScript の number を返せてしまう)
    • 基本的にこれを採用することない
  • GraphQL Code Generator で resolver の型定義を生成して、型が付いた状態で書く
    • @graphql-codegen/typescript-resolvers という plugin があり、これを使うとスキーマファイルから TypeScript 向けの resolver の型定義を生成できる
    • Resolvers という型が生成されるので、const resolvers: Resolvers = { ... } のようにして使う
      • 後は { ... } の中を好きに書く
    • コードファースト開発では大体これが採用される (という認識。他にナウい手段があるなら教えてください!)

コードファーストアプローチでは、スキーマをコードで定義する時に一緒に resolver も定義することになるので、この部分の技術選定はスキップされます。そのため、GraphQL Code Generator は基本的に導入しなくても問題ありません。ただ、Resolvers 以外の型定義 (例えば で生成できる GraphQL Object の型など) を生成したくなったら GraphQL Code Generator を導入することになりますが、そうしたものが欲しくなるまでは、導入しなくて良いでしょう。

おわりに

この記事では JavaScript における GraphQL サーバーを構成するライブラリやツールの整理をしました。GraphQL サーバーの技術選定をする際の参考になれば嬉しいです。

はてなエンジニア - Qiita Advent Calendar 2024 - Qiita の明日の担当は id:bps_tomoya さんです。

*1:ちなみに答えは No です

*2:もともと Meteor Development Group という団体が作ったライブラリだった: https://zenn.dev/username/articles/2023-07-17-944941f57183b6#%E6%AD%B4%E5%8F%B2

node --test と Vitest どちらを使うべきか

社内で「node --test って実際どうなの?」という議論がありました。その参考に、id:mizdranode --test 最近を触って思ったことや、感想などを書いてみます。

結論だけ先に書いておくと、node --test はミニマムなテスティングフレームワークです。テストする時に必要とされる機能は大体ありますが、Vitest と比較すると色々欠けてます。そして、Vitest と比べて何が足りないのか、実際に触ってみるまで意外と分からないです。とりあえず動けば OK という人は、Vitest 使ったら良いと思います。node --test はミニマリスト向けです。

node --test とは

まず node --test って何という人のために書いておくと、Node.js 組み込みのテストランナーのことです。Node.js v16.17.0, v18.0.0 から導入されてます。

基本的な使い方は、以下の記事を見るとわかりやすいです。

一応ざっと要点を書いておくと、

  • node --test "src/**/*.js" でテスト実行
  • node:test モジュールからテストのための utility が export されてる
    • describe, test, mock
    • Jest, Vitest とだいたい互換性のあるインターフェイス
  • assertion だけは node:assert から export されてる
    • node:assertnode:test より前からあった上に、テスト以外でも assertion が利用可能なので、別のモジュールになってる
    • インターフェイスも Jest, Vitest (BDD style) とは違う
  • TypeScript でテストを書くには --experimental-strip-types が必要 *1
    • node --test --experimental-strip-types "src/**/*.ts"
  • スナップショットテスト もできる
  • テストファイル更新時に rerun するには node --test --watch

id:mizdra は以下のリポジトリで使ってみています。

使ってみた感想

素朴だけど、それでも間に合うなら大丈夫という感じ。ミニマリストにはオススメです。

*1:ちなみにフラグなしでも TypeScript が利用できるような計画が進んでる: https://github.com/nodejs/typescript/issues/17

*2:最近安定化の PR がマージされたので、そのうち --test-snapshots でいけるようになるはず: https://github.com/nodejs/node/pull/55897

zsh + fzf で「あの時作業していたあのブランチ」を快適に探す

今まで id:mizdra はターミナルで Git ブランチを切り替えるときに、zsh + peco を使った Git ブランチ検索用のキーバインドを使用していた。

# .zshrc

function select-git-branch() {
  selected_branch=$(git branch | cut -c 3- | peco)
  BUFFER="${LBUFFER}${selected_branch}${RBUFFER}"
  CURSOR=$#LBUFFER+$#selected_branch
  zle redisplay
}
zle -N select-git-branch
bindkey '^b' select-git-branch

zsh + peco で Git ブランチを切り替える様子

便利っちゃ便利なのだけど...沢山のブランチの中から「あの時作業していたあのブランチ」を探すのが、どうも難しかった。ブランチの名前を覚えていれば良いけど、時々忘れてしまうこともある。もしそうなったら、以下のような手段でブランチを探すことになる。

  • ブランチ一覧から、それっぽいブランチを1つずつ git log して中身を見ていく
  • GitHub の Pull Request ページからブランチ名を探す (Pull Request が作成されている時限定)
  • git reflog で操作履歴を見て、それっぽいブランチを探す

最終的に目的のブランチは見つけられるが、どれも多少の手間が掛かる。そこで、もっと使い勝手の良い Git ブランチ検索機能が作れないのかなと思ったのだった。

どうやったら使い勝手が良くなるのか

まず第一に、ブランチを並べる順序を committer date の降順にするべきだと思う。経験上、最近作業したブランチほど切り替える確率が高いので、それが上にきて欲しい。ついでに committer date もブランチ名に並べて表示しておくと良いと思う。ブランチの名前は覚えていなくても作業した日付は案外覚えているもので (「大体3日前/1週間前/3ヶ月前に作業してたよな〜」とか)、ブランチを探すときのヒントになるはず。

あと自分のブランチなのか、他人のブランチなのかが人目で区別がつくと良いと思う。というのも、普段の作業では基本的に自分のブランチの中から探す傾向があり、一方レビューするときは他人のブランチの中から探す傾向がある。ブランチの author の区別がつけば、それがやりやすくなる。

ブランチ名、committer date、author さえあれば大抵は十分だろうが、それでも探したいブランチを特定できない時というのはあると思う。そうした時のために、ブランチの詳細を掘り下げて調べられるようにしたい。例えば commit message を出すとか。欲をいえば git log --oneline --graph --decorate 相当の情報が見れたら良い。

select-git-branch-friendly

という訳で欲しいやつを作った。

# .zshrc

# fzf のデフォルトのオプション。お好みで。ここでは peco っぽくなるよう調整してる。
export FZF_DEFAULT_OPTS="--reverse --no-sort --no-hscroll --preview-window=down"

user_name=$(git config user.name)
fmt="\
%(if:equals=$user_name)%(authorname)%(then)%(color:default)%(else)%(color:brightred)%(end)%(refname:short)|\
%(committerdate:relative)|\
%(subject)"
function select-git-branch-friendly() {
  selected_branch=$(
    git branch --sort=-committerdate --format=$fmt --color=always \
    | column -ts'|' \
    | fzf --ansi --exact --preview='git log --oneline --graph --decorate --color=always -50 {+1}' \
    | awk '{print $1}' \
  )
  BUFFER="${LBUFFER}${selected_branch}${RBUFFER}"
  CURSOR=$#LBUFFER+$#selected_branch
  zle redisplay
}
zle -N select-git-branch-friendly
bindkey '^b' select-git-branch-friendly

実際に動かしている様子がこれ。

select-git-branch-friendly を試している様子。

ブランチは committer date の降順で並べられていて、ブランチ名の横には committer date と commit message を表示している。本当は author も出したかったけど、あんまり出す項目が多すぎるとターミナルの横幅が小さい時に、表示内容の多くが見切れてしまうので断念した。代わりに、他の人が author のブランチは赤色にして区別できるようにしてる *1

また下部には git log --oneline --graph --decorate の出力を表示している。これで各々のブランチの詳細を掘り下げて調べられる。

実装解説

折角なので実装について解説する。といってもほとんど zsh + peco の時と同じで、違うところは主に以下の部分。

selected_branch=$(
  git branch --sort=-committerdate --format=$fmt --color=always \
  | column -ts'|' \
  | fzf --ansi --exact --preview='git log --oneline --graph --decorate --color=always -50 {+1}' \
  | awk '{print $1}' \
)

git branch --sort=-committerdate --format=$fmt --color=always

まず git branch --sort=-committerdate --format=$fmt --color=always でブランチの一覧を出力している。--sort=-committerdate で committerdate の降順に表示できる。各々のブランチをどう整形して出力するかは --format=$fmt で指定している。

user_name=$(git config user.name)
fmt="\
%(if:equals=$user_name)%(authorname)%(then)%(color:default)%(else)%(color:brightred)%(end)%(refname:short)|\
%(committerdate:relative)|\
%(subject)"

ちょっとややこしいけど、ブランチ名、現在時刻からの committer date の相対日時、commit message を | 区切りでくっつけてるだけ。| は後のフェーズで column コマンドで出力を整えるために使われる。

面白いのは authorname が自分以外の時に赤色にするのを、%(if:equals=$user_name)%(authorname)%(then)%...%(else)...%(end) という構文で実現しているところ。そんな構文あるの!!!って感じだけど Git の公式ドキュメントにもちゃんと書いてある。

あとパイプ使っている関係でそのままだと色が付かないので、--color=always をつけている。

column -ts'|'

| 区切りで垂直方向を揃えているだけ。

$ git branch --format=$fmt
main|4 weeks ago|Update dependency @mizdra/eslint-config-mizdra to v4
remove-workaround-for-source-map|4 weeks ago|remove workaround for source-map
tsserver-handle-css-modules|4 weeks ago|WIP

$ git branch --format=$fmt | column -ts'|'
main                              4 weeks ago  Update dependency @mizdra/eslint-config-mizdra to v4
remove-workaround-for-source-map  4 weeks ago  remove workaround for source-map
tsserver-handle-css-modules       4 weeks ago  WIP

ところで前のステップで %(authorname) が自分自身の時に %(color:default) を出力していたのには理由がある。もし %(color:default) がなくて、main が他の人のブランチだった場合、column -ts'|' は以下を出力する。

$ git branch --format=$fmt | column -ts'|'
main                          4 weeks ago  Update dependency @mizdra/eslint-config-mizdra to v4
remove-workaround-for-source-map  4 weeks ago  remove workaround for source-map
tsserver-handle-css-modules       4 weeks ago  WIP

main ブランチの行が 4 文字分左に寄ってしまっているのが分かると思う。これは %(color:brightred)ANSI escape sequence\e[91m を意味し、これが 4 文字としてカウントされてしまっているため *2

そして %(color:default) は ANSI escape sequence の \e[39m を意味する。そのため、これを含めておけば \e[91m\e[39m で文字数が揃って column -ts'|' の出力が整う。

なんだか不安になるハックだが...動けばオッケーということで。もっと賢い方法知ってる人がいたら教えて下さい。

fzf --ansi --exact --preview='git log --oneline --graph --decorate --color=always -50 {+1}'

見て分かる通り、peco の代わりに fzf を使っている。個人的には peco のほうが好きなのだけど、Preview window なる機能が使いたくて、仕方なく fzf にしてる。

--preview='git log --oneline --graph --decorate --color=always -50 {+1}' の部分がその Preview window というやつで、これで下部に git log を出してる。たったこれだけで格好良い UI 出せるの便利。git log-50 を渡しているのは、出力する commit 履歴を絞るため。これがないと全履歴を出そうとして、巨大リポジトリで大破滅する。まあ 50 件あったら十分だろうと思って -50 にしてる。

--ansi は前段からやってくる色付き (ANSI Escape sequence 込み) の入力を fzf が解釈できるようにするために必要。あと fuzzy search あんまり便利じゃないなと思ったので、--exact 付けてる。typo してもマッチする便利さよりも、厳密に絞り込めないことで検索結果が noizy になる不便さのほうが勝るなと感じたので。ここは好みによると思う。

awk '{print $1}'

ブランチ名、committer date、author が含まれる行からブランチ名を取り出してるだけ。これで $selected_branch に選択したブランチが代入される。

感想

個人的にはかなり使いやすいものができて満足してる。ブランチを探す時の傾向やパターンから、使い勝手の良い UI を設計するというのも面白かった。あと fzfgitcolumnawk だけでこれだけリッチな UI が作れるんだなという驚きもあった。

皆さんも使ってみてください。

追記 (2024-10-19 18:122)

今気づいたけど、fzf 公式のプラグインが似たようなキーバインドを実装しているようだった。

github.com

# 引用元: https://github.com/junegunn/fzf-git.sh/blob/6a5d4a923b86908abd9545c8646ae5dd44dff607/fzf-git.sh#L59-L62
# 見やすいように一部整形している。
$fmt='\
%(HEAD) %(color:yellow)%(refname:short) %(color:green)(%(committerdate:relative))\t\
%(color:blue)%(subject)%(color:reset)\
'
if [[ $# -eq 1 ]]; then
  branches() {
    git branch "$@" --sort=-committerdate --sort=-HEAD --format=$fmt --color=$(__fzf_git_color) | column -ts$'\t'
  }
  # ...
}

僕が作ったのと同じように、committer date でソートし、committer と commit message を出している。よくみると column コマンドも同じように使ってる。author で色分けするのはやってない模様。

Preview window で git log --oneline --graph を出すのも同じだった。けどこちらは多少 format が異なるようだった。

# 引用元: https://github.com/junegunn/fzf-git.sh/blob/6a5d4a923b86908abd9545c8646ae5dd44dff607/fzf-git.sh#L185-L200
# 見やすいように一部整形している。
_fzf_git_branches() {
  _fzf_git_check || return
  bash "$__fzf_git" branches |
  _fzf_git_fzf --ansi \
    # ...
    --preview "git log --oneline --graph --date=short --color=$(__fzf_git_color .) --pretty='format:%C(auto)%cd %h%d %s' \$(sed s/^..// <<< {} | cut -d' ' -f1) --" "$@" |
  sed 's/^..//' | cut -d' ' -f1
}

まあでも大体僕のやつと同じっぽい。まさか車輪の再発明をしていたとはね...。

*1:最初は自分のブランチの色も緑色に変えていたけど、fzf がクエリにマッチする文字のハイライトに緑色を使っていて、それと競合するのでやめた。そもそもあんまりカラフルにしてもゴチャゴチャした見た目になるので、色数は少なく保ったほうが良いはず。

*2:6文字じゃないのかと思うかもしれないが、\e は 2 文字で 1 つのエスケープ文字を表すので、5文字とみなすのが正しい。それでも1文字多くて不思議だけど、多分 column コマンドがエスケープ文字を無視して整形しているのではないか... なんかこれ column コマンドの実装によって挙動変わってきそうで怖いな...

React Server Components で時限式コンポーネントを作る

特定の時間になったらコンテンツをページに出したい、ということがあると思う。漫画サービスなら「ゴールデンウィーク限定!全話無料キャンペーン!」みたいなのとか。

普段の業務ではこうしたことを実現するために、時限式コンポーネントや、ScheduledComponent などと呼ばれるものを作ってる *1

// components/ScheduledComponent.tsx
export function ScheduledComponent({showAt, children}: {
  showAt: Date;
  children: React.ReactNode;
}) {
  if (new Date() < showAt) {
    return null;
  } else {
    return children;
  }
}

これを Next.js Pages Router などから、以下のようにして使う。

// pages/index.page.tsx
import { ScheduledComponent } from "@/components/ScheduledComponent";

export default function Home() {
  return (
    <main>
      <ScheduledComponent showAt={new Date("2024-01-01T00:00:00.000Z")}>
        <h1>Happy New Year Campaign!</h1>
      </ScheduledComponent>
      <ScheduledComponent showAt={new Date("2024-12-25T00:00:00.000Z")}>
        <h1>Christmas Campaign!</h1>
      </ScheduledComponent>
    </main>
  );
}

Client Components では秘密のキャンペーン情報が漏洩する

ところで Next.js Pages Router では、コンポーネントはすべて Client Components となる。Client Components はその全てのソースコードがクライアント向けの bundle に含まれる。そのため、ユーザは Client Components のソースコードを (勿論 minify はされているが) 読める。

その結果、まだキャンペーン開始日になっていないにも関わらず、クライアントの bundle からキャンペーン情報が漏れてしまう。実際に Chrome devtools の Network パネルの検索機能を使ってみると、クライアントの bundle に非公開のキャンペーン情報が漏れていることがわかる。

クリスマス前なのにクリスマスキャンペーンの情報が漏洩している様子のスクリーンショット。
クリスマス前なのにクリスマスキャンペーンの情報が漏洩している様子。

解決策: Server Components を使う

Server Components を使うと、この問題を解決できる。Server Components はクライアントの bundle には一切含まれない。そのため、キャンペーン開始日まではユーザにその情報を隠しつつ、時間になったら公開できる。

Next.js App Router なら以下のように書けば良い。うっかり <ScheduledComponent> が Client Components にならないよう、import 'server-only'; を付けておくのがポイント *2

import 'server-only';

export function ScheduledComponent({showAt, children}: {
  showAt: Date;
  children: React.ReactNode;
}) {
  if (new Date() < showAt) {
    return null;
  } else {
    return children;
  }
}
// app/page.tsx
import { ScheduledComponent } from "@/components/ScheduledComponent";

export default function Home() {
  return (
    <main>
      <ScheduledComponent showAt={new Date("2024-01-01T00:00:00.000Z")}>
        <h1>Happy New Year Campaign!</h1>
      </ScheduledComponent>
      <ScheduledComponent showAt={new Date("2024-12-25T00:00:00.000Z")}>
        <h1>Christmas Campaign!</h1>
      </ScheduledComponent>
    </main>
  );
}

Chrome devtools の Network パネルの検索機能 で見てみても、クリスマスキャンペーン情報が無事漏洩しなかったことが確認できる。

クリスマスキャンペーン情報がヒットしていない様子のスクリーンショット。
クリスマスキャンペーン情報がヒットせず、情報が漏洩していないことがわかる。

参考情報

Client Components がクライアントの bundle に含まれることや、Server Components が含まれないことを知らなかった方は、以前 id:mizdra が発表した以下の資料を読んでみることをオススメします。Server Components の仕組みや漏洩の仕組みの話について詳しく書いてあります。

どうしても Next.js Pages Router で時限式コンポーネントを作りたい場合

とにかくクライアントの bundle にキャンペーン情報を含めてしまうとアウトなので、React Components に一切キャンペーン情報を書かない、というのが鉄則となる。となるとじゃあどこにキャンペーン情報を書いたらええねんという話になるのだけど... API Routes を使えば良いと思う。どういうことかと言うと、/api/get-secret-campaign みたいな API を API Routes で生やして、そこからキャンペーン情報を返したら良い。

// pages/api/get-secret-campaign.tsx
import type { NextApiRequest, NextApiResponse } from 'next';

export default function handler(
  req: NextApiRequest,
  res: NextApiResponse,
) {
  if (new Date() < new Date("2024-12-25T00:00:00.000Z")) {
    res.status(200).send('');
  } else {
    res.status(200).send('<h1>Christmas Campaign!</h1>');
  }
}
// pages/index.page.tsx
import { ScheduledComponent } from "@/components/ScheduledComponent";

export const getServerSideProps = (async () => {
  const res = await fetch('https://example.com/api/get-secret-campaign');
  const secretCampaignHTML = await res.text();
  return { props: { secretCampaignHTML } };
}) satisfies GetServerSideProps;
 
export default function Home({
  secretCampaignHTML,
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
  return (
    <main>
      {secretCampaignHTML && <div dangerouslySetInnerHTML={secretCampaignHTML} />}
    </main>
  );
}

ちなみに /api/get-secret-campaign という API のパスはクライアントの bundle に含まれることになるので、このパスに秘密の文字列を書かないよう注意が必要。

plain な HTML じゃなくてコンポーネントを返したいなら場合は、react-dom/serverrenderToString を使えばできるとは思う。まあでも CSS 送りたい時はどうするのとか、ユーザイベントに反応するコンポーネントを作るにはどうするの *3 とか色々面倒な検討事項があるから、やりたくはないけど...。

とはいえこうして振り返ってみると、Server Components が導入されたことで Client Components だけあった時代よりも時限式コンポーネントを作るのがずっと簡単になった、ということが分かると思う。

検証に使ったコード

github.com

*1:TimeWall コンポーネントと呼ぶこともある。こっちのほうが格好良いとは思う。

*2:'use client' ディレクティブを付けない場合は、Client Components にも Server Components にもなりうる。Server Components にだけなるよう強制したければ、server-only モジュールを import する必要がある。詳しくは https://speakerdeck.com/mizdra/react-server-components-noyi-wen-wojie-kiming-kasu?slide=70 を参照。

*3:dangerouslySetInnerHTML で挿入されるのは plain な HTML であり、イベントハンドラが一切設定されていない。そのためユーザのクリックイベントなどには何も反応できない。

builderscon 2024 に参加 & 登壇発表しました

2024/08/10 (土) に「builderscon 2024」に参加してきました。

fortee.jp

発表

ありがたいことに「React Server Components の疑問を解き明かす」という題で発表させて頂くことができました。

speakerdeck.com

React Server Component (RSC) は、サーバーサイド上でレンダリングされる React コンポーネントです。React Server Component を用いると、コンポーネントの中から DB にアクセスしたり、ファイルシステムにアクセスしたりできます。

一方で、今までにも erb や pug といったテンプレートエンジンのように、サーバーサイド上でテンプレートをレンダリングする技術というのは存在しました。また、React でも SSR を用いれば、(Client) Component をサーバー上でレンダリングできました。そのためこれらの技術とどう違うのか、疑問を抱いている方も多いはずです。

そうした方向けに、RSC とは何か、どんなことができるのか、そして従来の技術とどう違うのか、どう開発スタイルが変わるのかについて話したのが、本発表となってます。普段フロントエンドを触っている方は勿論、それ以外の技術領域の方もターゲットにした内容となっています。是非読んでみてください。

また、当日の発表では 68 枚のスライドで話しましたが、Speaker Deck にアップロードしたものには付録として、20 枚の追加スライドがあります。当日会場で話を聞いていた方も、是非 Speaker Deck で読んでみてください。

僕と builderscon

僕が builderscon に参加したのは今年で 2 回目です。最初に参加したのは、「builderscon 2019」でした。なんと参加した当時は id:mizdra はまだ学生でした。

何人くらいの規模のイベントだったかよく覚えてませんが、少なくとも 500 人以上だったはず? 沢山の人が来られて、皆楽しそうにされていたのが印象深かったです。自分もこういう場でいつか登壇してみたいなと、そう思えるイベントでした。

しかし COVID-19 流行により、翌年以降の builderscon は未開催となっていました。つまり、「builderscon 2019」が僕が参加した最初で最後の builderscon でした。

そうした背景もあり、今年無事 builderscon が開催されてとても嬉しかったです。加えて、builderscon で登壇したいという思いを 5 年越しに叶えることができました。本当に良かったです。

開催に携わったスタッフの皆さん、ありがとうございました! また来年も楽しみにしています。

tsconfig.json の include オプションには何を指定すべきか

TL;DR

  • "include": ["src/index.ts"] はやめよう
    • src 配下にあるのに型チェックされない & auto-import できないファイルが生まれてしまう
  • "include": ["src/**/*"]"include": ["**/*"] がオススメ
    • どっちが良いかはプロジェクトによる
    • "include": ["src/**/*"]"include": ["src"] と、"include": ["**/*"] は include 指定無しと同じなので、それでも OK
  • すっごい凝りたいなら Solution Style tsconfig.json を使おう

はじめに

tsconfig.jsoninclude オプションは、プロジェクトを構成するファイルを指定するオプションです。

例えば src/**/* を指定すると、src ディレクトリ以下の全てのファイル *1 がプロジェクトに所属するようになります。プロジェクトに所属するファイルは、型チェックやコンパイルの対象になります。

{
  "include": ["src/**/*"]
}
├── scripts
│   └── lint.ts         ⨯
├── src
│   ├── util
│   │    └── log.ts     ✓
│   ├── index.ts        ✓
│   └── math.ts         ✓
├── package.json
├── tsconfig.json
└── vitest.config.ts    ⨯

人によっては "include": ["src/**/*"] 以外にも、"include": ["src"], "include": ["src/index.ts"], "include": ["**/*"] などと指定することもあります。一見するとどれも同じように見えますが、実は挙動が異なります。この記事では、include オプションの指定方法によってどのような挙動になるのか、どう指定するのが良いのかについて考えてみます。

include オプションとそれに関連する仕様について

本題に入る前に、まずは include オプションやそれに関連する仕様について説明します。

プロジェクトに所属しないファイルは tsc -p tsconfig.json による型チェックの対象にならない

プロジェクトに所属しないファイルは tsc -p tsconfig.json による型チェックの対象になりません。先ほどの例で言うと、scripts/lint.tsvitest.config.tstsc コマンドによる型チェックの対象にならず、tsc -p tsconfig.json で型エラーが報告されません。

プロジェクトに所属しないファイルでも、エディタ上で型チェックされる

ややこしいことにプロジェクトに所属しないファイルも、エディタ上では型チェックの対象になります (厳密にはエディタではなく、「tsserver」と呼ばれる TypeScript の Language Server 実装に由来する振る舞いです。)。そのため、エディタ上では型エラーが報告されるものの、tsc -p tsconfig.json では報告されません。

また、型チェックに使われる設定は tsconfig.json で定義されているものではなく、tsserver 組み込みのデフォルトの設定が使われます *2。そのため、tsconfig.json"noImplicitAny": true を設定していても、デフォルトの設定である "noImplicitAny": false 相当で型チェックが行われてしまいます。

その結果、「エディタでは型エラーが出ているけど、CI を pass してしまう」「普段と異なる設定で型チェックが行われてしまう」といった問題に繋がりがちです。

プロジェクトに所属しないファイルは auto-imports の対象にならない

TypeScript には auto-imports という機能があります。auto-imports は、他のファイルに定義されている変数や関数を使おうとしたときに、補完の候補に出して、自動的に import 文を挿入してくれる機能です。

add 関数が補完の候補に出てきて、選択すると src/math.ts の import 文が挿入される様子

しかし、プロジェクトに所属しないファイルは auto-imports の対象になりません。もし仮に src/math.ts がプロジェクトに所属していない場合、src/math.ts に定義されている関数を使おうとしても、補完候補に出てきません。

add 関数が補完候補に出てこない様子

include にマッチしなくても、include にマッチするファイルから import されていればプロジェクトに所属する

ややこしいことに、include にマッチしないファイルでも、include にマッチするファイルから import されていればプロジェクトに所属すると見なされます。

例えば、以下のようなリポジトリがあるとします。

{
  "include": ["src/index.ts"]
}
├── src
│   ├── util
│   │    └── log.ts
│   ├── index.ts
│   └── math.ts
└── tsconfig.json
// src/index.ts
import { add } from "./math.js";

src/index.ts から math.ts が import されていますから、math.ts はプロジェクトに所属すると見なされます。そのため、math.tstsc -p tsconfig.json による型チェックの対象になりますし、auto-imports の対象にもなります。一方、util/log.ts はプロジェクトに所属しないと見なされます。

つまり include オプションにマッチしないからといって、プロジェクトに所属しないファイルとは限らないということです。

"include": ["src/**/*"]"include": ["src"] は同じ

TypeScript のソースコードを読んでみると、"include": ["src"]"include": ["src/**/*"] と同義のようでした。

"include" が指定されていない場合は "include": ["**/*"] と同じ

TypeScript のソースコードを読んでみると、"include" が指定されていない場合は "include": ["**/*"] と同じ挙動になるようでした。

結局 include オプションには何を指定したら良いの?

以下のようなディレクトリ構成をもとに話を進めます。

├── scripts
│   └── lint.ts
├── src
│   ├── util
│   │    └── log.ts
│   ├── index.ts
│   └── math.ts
├── package.json
├── tsconfig.json
└── vitest.config.ts
// src/index.ts
console.log('Hello, world!');

悪い例: "include": ["src/index.ts"]

src/math.tssrc/util/log.ts がプロジェクトに所属しないとファイルと見なされてしまい、tsc -p tsconfig.json で型チェックされなかったり、auto-imports の対象にならなかったりといった問題が発生します。この設定は絶対に避けるべきです。

プロジェクトに含まれるファイル:

├── scripts
│   └── lint.ts         ⨯
├── src
│   ├── util
│   │    └── log.ts     ⨯
│   ├── index.ts        ✓
│   └── math.ts         ⨯
├── package.json
├── tsconfig.json
└── vitest.config.ts    ⨯

悪い例: "include": ["src/*"]

先ほどと違い、src/math.ts はプロジェクトに所属すると見なされますが、src/util/log.ts は所属しないと見なされます。これも避けるべきです。

プロジェクトに含まれるファイル:

├── scripts
│   └── lint.ts         ⨯
├── src
│   ├── util
│   │    └── log.ts     ⨯
│   ├── index.ts        ✓
│   └── math.ts         ✓
├── package.json
├── tsconfig.json
└── vitest.config.ts    ⨯

良い例: "include": ["src/**/*"] or "include": ["src"]

src 配下の全てのファイルがプロジェクトに所属すると見なされ、tsc による型チェックや auto-imports の対象になります。

プロジェクトに含まれるファイル:

├── scripts
│   └── lint.ts         ⨯
├── src
│   ├── util
│   │    └── log.ts     ✓
│   ├── index.ts        ✓
│   └── math.ts         ✓
├── package.json
├── tsconfig.json
└── vitest.config.ts    ⨯

scripts/lint.tsvitest.config.ts は型チェックや auto-imports の対象になりませんが...そもそもこうしたファイルを src と同じ設定で型チェックしたいかというと、場合によると思います。例えば、src 配下がブラウザで実行されるファイルが置かれていたら、src 配下は "lib": ["ESNext", "DOM"] で、それ以外の Node.js で実行されるファイルは "lib": ["ESNext"] で型チェックしたいかもしれません。そうしたケースでは、むしろ scripts 配下や vitest.config.ts が含まれないこのパターンが適しているでしょう。

余談: scripts/lint.tsvitest.config.ts を別の設定で型チェックしたい場合

tsconfig.json を Node.js 向けの設定ファイルとして使い、src 配下のファイルだけを型チェックする設定ファイル (src/tsconfig.json) を別途用意すると良いです。

├── scripts
│   └── lint.ts         ✓ included by tsconfig.json
├── src
│   ├── util
│   │    └── log.ts     ✓ included by src/tsconfig.json
│   ├── index.ts        ✓ included by src/tsconfig.json
│   ├── math.ts         ✓ included by src/tsconfig.json
│   └── tsconfig.json   ✓ included by src/tsconfig.json
├── package.json
├── tsconfig.json
└── vitest.config.ts    ✓ included by tsconfig.json
// tsconfig.json
{
  "exclude": ["src"],
  "compilerOptions": {
    "lib": ["ESNext"]
  }
}
// src/tsconfig.json
{
  "compilerOptions": {
    "lib": ["ESNext", "DOM"]
  }
}

src/tsconfig.json のように src ディレクトリの中にあると取り回しづらいから、tsconfig.browser.json に rename してプロジェクトルートに置きたい...と思うかもしれませんが、素朴にやってしまうと tsserver がから検出できなくなってしまいます (詳しくは以下の記事を参照)。

もしプロジェクトルートに設定ファイルを集約したければ、Solution Style tsconfig.json というテクニックを使うと良いです。

├── scripts
│   └── lint.ts         ✓ included by tsconfig.node.json
├── src
│   ├── util
│   │    └── log.ts     ✓ included by tsconfig.browser.json
│   ├── index.ts        ✓ included by tsconfig.browser.json
│   └── math.ts         ✓ included by tsconfig.browser.json
├── package.json
├── tsconfig.json
├── tsconfig.browser.json
├── tsconfig.node.json
└── vitest.config.ts    ✓ included by tsconfig.node.json
// tsconfig.json
{
  "files": [],
  "references": [
    { "path": "./tsconfig.browser.json" },
    { "path": "./tsconfig.node.json" }
  ]
}
// tsconfig.browser.json
{
  "include": ["src"],
  "compilerOptions": {
    "lib": ["ESNext", "DOM"]
  }
}
// tsconfig.node.json
{
  "exclude": ["src"],
  "compilerOptions": {
    "lib": ["ESNext"]
  }
}

良い例: "include": ["**/*"] or include 指定なし

全てのファイルがプロジェクトに所属すると見なされ、tsc による型チェックや auto-imports の対象になります。src 配下が Node.js で実行するファイルであり、scripts 配下や vitest.config.ts とまとめて型チェックしてしまって問題ない場合は、これで良いでしょう。

プロジェクトに含まれるファイル:

├── scripts
│   └── lint.ts         ✓
├── src
│   ├── util
│   │    └── log.ts     ✓
│   ├── index.ts        ✓
│   └── math.ts         ✓
├── package.json
├── tsconfig.json
└── vitest.config.ts    ✓

参考

検証に使ったリポジトリを置いておきます。

github.com

*1:厳密には、src ディレクトリ以下の全てのファイルのうち、.ts/.mts/.cts で終わるファイルのマッチします。allowJS や checkJS オプションが有効な場合は、.js/.mjs/.cjs にもマッチします。

*2:プロジェクトに所属しないファイルは、tsserver の内部で「inferred project」という特殊なプロジェクトに属すると見なして型チェックが行われています。

閉じている details 要素に `#fragment` でジャンプして、ジャンプと同時に展開したい

最近用語集のようなページを作る機会があった。それぞれの用語の名前と説明がバーっと並んでて、説明部分は <details> 要素で隠されている。用語の名前をクリックすると <details> が展開されて、説明部分が読める、といった感じ。

JavaScript, C++, Python という3つの用語の説明が並んでいる。それぞれの用語の説明は details 要素で構成されていて、JavaScript と Python は展開されていて、C++ は展開されていない。
用語集ページの例

他のページから特定の用語の説明に飛べるように、用語を表す要素それぞれに id 属性が振ってある。例えば https://glossary.example.com/#javascript という URL でアクセスすると、「JavaScript」の用語がある位置にスクロールした状態で、用語集ページを開ける。

ただ (一部ブラウザで) https://glossary.example.com/#javascript でアクセスしても、JavaScript の説明の欄が閉じたままで困っていた。その用語の説明が見たいがためにしてきているので、最初から説明の欄は展開されていて欲しい。

Auto-expand details elements

実は、まさにこれを実現するための機能が標準化されている (「Auto-expand details elements」と呼ばれてる)。

厳密にはこの機能は、#id-or-name による navigation が発生した時だけでなく、Text Fragment (#:~:text=...) による navigation や、ページ検索の際にも、<details> 要素の自動展開を行う仕様となってる。

Chrome や Edge では実装済みで、Safari や Firefox では未実装。そのため、Chrome や Edge であれば、 https://glossary.example.com/#javascript でアクセスすれば、最初から JavaScript の説明欄が展開された状態でページを開ける。

shim

そのうち他のブラウザでも実装が進むだろうけど、待っていられないので shim *1 を書いた。

以下のデモページからお試しいただけます。

やってることはシンプルで、ページ表示時と <a> 要素をクリックした時に、閉じている <details> 要素に fragment navigation しようとしてたら展開するだけ。

注意点としては、「Auto-expand details elements」機能の全ての挙動を模倣している訳では無いということ。先述したように「Auto-expand details elements」機能にはページ検索や Scroll to Text Fragment で <details> 要素にヒットした時に、その <details> 要素を自動展開する挙動があるが、shim ではその挙動までは再現していない。そうした挙動まで再現するのは大変すぎるし、id:mizdra は別に必要としてないので。

あと View フレームワークによっては、DOMContentLoaded イベントリスナーが上手く設定できなくて、そのままでは shim が利用できないかも。また動的に <a> 要素がページに挿入される場合は、その <a> 要素にも a.addEventListener("click", ...) する必要がある。アプリケーションごとに事情が変わってくると思うので、アプリケーションごとに上手くやってください。

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

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