Cloudflare Workersで手軽にRESTful APIを公開する

今回は、OpenAPI Specification から良い感じのドキュメントサイトを提供してくれるサービス bump.sh を見つけたので、RESTful API を用意して試してみます。

ドキュメントサイトがあることで、API が公開されていることがより分かりやすくなるでしょう。

こちらはスキーマの差分を解析するツール API Diff · Powered by Bump.sh を提供しており、今後 GraphQL もサポートされるそうなので楽しみです。

RESTful API

まずは JSON を返すだけの簡単なAPIを作りたいと思います。 軽量なアプリケーションとなるので Cloudflare Workers をデプロイ先とします。

手書きしたくない

ゼロから書くので定義ファイルとサーバーサイドの実装どちらも手書きすることはなるべく避けたいところです。

Node.js では path 毎に対応するスキーマの定義をコロケーション的に記述できる express-openapi や定義ファイルからサーバのコードを生成出来る OpenAPITools/openapi-generator の nodejs-express-server などがありそうでした。

Cloudflare Workers

では Cloudflare Workers に適したものはあるのでしょうか?

Cloudflare Workers でも動作するフレームワークといえば honojs/hono: Ultrafast web framework for the Edge があります。

OpenAPI and SwaggerUI support · Issue #717 · honojs/hono によると hono は今の所 OpenAPI についてサポートはしていないようですが、代わりに cloudflare/itty-router-openapi が紹介されていました。

今回はこちらを使ってみることにしましたが、他にも良さそうなものがあれば教えていただけると幸いです。

cloudflare/itty-router-openapi

github.com

cloudflare/itty-router-openapi を使うとコードベースから定義ファイルを生成できる RESTful API のアプリケーションを作ることが出来ます。 express-openapiと似たように、path 毎に対応するスキーマの定義をおこなっていく方法のようです。

当社の開発方法:Cloudflare Radar 2.0を支える技術 で紹介されており、実際の製品に使われているところも安心して使えそうです。

実装

github.com

今回作ってみたものを例にすると以下のような雰囲気の実装になります。

パラメータやレスポンスをコロケーション的に記述出来るのは、実装していく際に何を受け取って何を返すべきかが明確になって良さそうでした。

const router = OpenAPIRouter()
class getSeason extends OpenAPIRoute {
  static schema = {
    parameters: {
      season: Path(Number, {
        description: 'Season number',
        required: true,
      }),
    },
    responses: {
      '200': { schema: { season: SeasonSchema } },
      '404': {
        description: 'Not found season',
        schema: { message: new Str({ example: 'Not found season' }) },
      },
    },
  }
  async handle(props: HandleProps) {
    const seasonNumber = props.params['season']
    if (!seasonNumber) return newResponse({ message: 'Not found season' }, 404)

    const season: T | undefined = data[Number(seasonNumber)]
    if (!season) return newResponse({ message: 'Not found season' }, 404)
    return newResponse({ season })
  }
}
router.get(`/xxx`, getSeason)

テスト

itty-router-openapi では定義したスキーマのレスポンスと handle メソッドが返す値の型があっているかどうかを確認することはできません。

そこで jest-openapi を使うと戻り値がOpenAPIドキュメントに沿っているかテストすることが出来ます。

なぜか素朴な Response 型には対応していなかったので、手で温かみのあるレスポンスを用意してあげましょう。

const dummyRequest = async (path: string) => {
  const target = `${SERVER_URL}/${path}`
  const request = new Request(target, {
    method: 'GET',
  })
  const response: Response = await router.handle(request)
  return {
    status: response.status,
    req: { path: target, method: request.method },
    body: await response.json(),
  }
}
const res = await dummyRequest(`/current`)
expect(res.status).toEqual(200)
expect(res).toSatisfyApiSpec()

OpenAPI Specification

ハンドラを登録した OpenAPIRouter の schema を JSON ファイルとして書き出すスクリプトを用意するだけで定義ファイルを生成することができます。

const router = OpenAPIRouter()
router.get(`/xxx`, OpenAPIRoute)
fs.writeFileSync('./public-api.json', JSON.stringify(router.schema, null, 2))

Bump.sh

ようやく?当初の目的である bump.sh を使うことが出来ます。

先程生成した定義ファイルをアップロードすることで以下のようなページを用意してくれました。Swagger UIと比べてもモダンで良い感じです。

bump-sh/github-action: GitHub action to deploy your API documentation on Bump

GitHub Actions もサポートされているので、デプロイと同時にドキュメントを自動更新することが出来て便利です。

https://bump.sh/kasu/doc/game-season-api

Better Uptime

Public Status Page | Better Uptime

JSON を返すだけなので落ちることはなさそうですが、せっかくなので Better Uptime を使ってかっこいいステータスページも用意しておきましょう。

任意のエンドポイントとそのリクエストを指定すると定期的にヘルスチェックを行ってくれます。

https://game-season-api.betteruptime.com/

YouTube の iframe を埋め込んだまま PageSpeed Insights のスコアを改善する

4行で

  • YouTube から提供される動画の埋め込みコードをそのまま使うと PageSpeed Insights のスコアが落ちる。
  • しかし、lite-youtube-embed などのライブラリでダミー画像を表示しておくと再生するまでは偽物感が残ってしまう。イヤだ!
  • そこで、ページが表示された後に iframe を遅延読み込みすることにした。
  • ブラウザの iframe の遅延読み込み機能window.onload では効果がなく、IntersectionObserver を使うことで PageSpeed Insights のスコアが改善できた。

PageSpeed Insights

最近、個人サイト kasu.dev の静的サイトジェネレータを Astro(astro.build)に置き換えました。

ふと PageSpeed Insights(https://pagespeed.web.dev/)で計測してみたら、思っていたよりもスコアが低かったのでなんとかします。

診断結果をみると、4つあるYouTubeの埋め込みに関わるリソースの取得が原因のようです。

実際に埋め込みをなくして測ると100点になったので、やっぱりアイツが犯人です。

スコアの改善方法

PageSpeed Insights ではページの読み込み時に行われる iframe 関連のリソース取得がスコアに大きく影響していました。 そこで、ページの読み込み時と iframe 関連リソースの取得タイミングをズラす方法を考えてみます。

再生するまでダミー要素を表示する

よくある改善方法として、ダミーの要素を用意しておきフォーカスやクリックされたタイミングで実際の iframe に置き換える方法があります。

YouTube の動画IDからサムネイル画像を取得してそれっぽく見せるやつですね。いくつかライブラリもありそうです。

github.com github.com

ライブラリのデモなどを見ていただくと分かるのですが、あくまでダミーなので埋め込まれた本物のYouTubeのUIと比べると違和感が残ってしまいます。 そのため今回は個人サイトで使用することを見送りました。

また、頑張って UI を精巧に真似ることができたとしても YouTube 側の更新に追従していくことが大変でしょう。

ブラウザの iframe の遅延読み込み機能

web.dev

上記の記事では iframe 固有の遅延読み込みの動作について説明があり、iframe を遅延読み込みするには loading="lazy"style="visibility: hidden;" を指定すれば良いとあります。

実際に手元でも遅延読み込みされている様子を確認できましたが、PageSpeed Insights のスコアに改善が見られないので別の方法を検討していきます。

また、記事内で紹介されていた aFarkas/lazysizes なら期待できるかもしれませんが、今回の要件には不必要な実装が多く含まれていそうだったので詳しくはみていません。

iframe を直接操作して遅延読み込みする

ブラウザの機能に頼ることは難しいことが分かりました。 そこで、今回は iframe を直接操作して遅延読み込みを行うことにします。

window.onload

window.onload にはページリソースの読み込みが完了した段階で呼ばれるコールバック関数を登録できるので、このタイミングで iframe を表示してみます。

その結果... PageSpeed Insights のスコアは改善しませんでした。setTimeout などで遅延読み込みのタイミングをより遅らせると改善するかもしれません。

実装例

window.onload 版のコンポーネント

---
export interface Props {
  id: string;
}
const { id } = Astro.props;
---

<div class="youtube-embed aspect-w-16 aspect-h-9">
  <iframe
    title="YouTube Embed"
    class="border-0"
    data-src={`https://www.youtube.com/embed/${id}`}
    allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
    allowfullscreen={true}></iframe>
</div>

<script>
  window.addEventListener("load", () => {
    const targets = document.querySelectorAll(".youtube-embed");
    targets.forEach((target) => {
      const embed = target;
      const iframe = embed.children[0] as HTMLIFrameElement | null;
      if (!iframe) return;

      const src = iframe.getAttribute("data-src");
      if (!src) return;

      iframe.setAttribute("src", src);
      iframe.onload = () => iframeOverlay?.remove();
    });
  });
</script>

IntersectionObserver

なんとかしたい個人サイトでは、ファーストビューではなくページのスクロール後に YouTube の埋め込み動画が登場します。 そこで今回は IntersectionObserver を用いて、ページのスクロールで iframe を表示したい要素に近づいたタイミングで遅延読み込みを行います。

実装例

実際のコンポーネント YouTubeEmbed.astro は下記のようになりました。

<!-- 利用例 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
  <YouTubeEmbed id="h4VJGNNSQnw" />
</div>
---
// YouTubeEmbed.astro
export interface Props {
  id: string;
}
const { id } = Astro.props;
---

<div class="youtube-embed aspect-w-16 aspect-h-9">
  <iframe
    title="YouTube Embed"
    class="border-0"
    data-src={`https://www.youtube.com/embed/${id}`}
    allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
    allowfullscreen={true}></iframe>
</div>

<script>
  const callback: IntersectionObserverCallback = (entries, observer) => {
    entries.forEach((entry) => {
      if (!entry.isIntersecting) return;

      const embed = entry.target;
      const iframe = embed.children[0] as HTMLIFrameElement | null;
      if (!iframe) return;

      const src = iframe.getAttribute("data-src");
      if (!src) return;

      iframe.setAttribute("src", src);
      observer.unobserve(embed);
    });
  };

  const observer = new IntersectionObserver(callback, {
    rootMargin: "1000px",
  });

  const targets = document.querySelectorAll(".youtube-embed");
  targets.forEach((target) => observer.observe(target));
</script>

Astro の事情を反映した実装箇所はイカ

Script bundling
pass-frontmatter-variables-to-scripts
動作例

IntersectionObserver で遅延読み込みしてみた様子がこちら。

このままだと、iframe の読み込み時に白い背景が表示されてしまい、ダークモードのサイトでは眩しいと思うのでなんとかしていきます。

読み込み中の iframe の上に Placeolder を表示しておき、iframe の読み込み完了時に呼ばれる onload を使って Placeolder を隠すのはどうでしょうか。

<div class="youtube-embed aspect-w-16 aspect-h-9">
    <iframe>...</iframe>
+   <div class="w-full h-full bg-base-300"></div>
</div>

<script>
    ...
    const iframe = embed.children[0] as HTMLIFrameElement | null;
+   const iframeOverlay = embed.children[1] as HTMLDivElement | null;
+   if (!iframe || !iframeOverlay) return;
    ...
    iframe.setAttribute("src", src);
+   iframe.onload = () => iframeOverlay.remove();
    ...
</script>

最終的なコードはこちら

---
export interface Props {
  id: string;
}
const { id } = Astro.props;
---

<div class="youtube-embed aspect-w-16 aspect-h-9">
  <iframe
    title="YouTube Embed"
    class="border-0"
    data-src={`https://www.youtube.com/embed/${id}`}
    allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
    allowfullscreen={true}></iframe>
  <div class="w-full h-full bg-base-300"></div>
</div>

<script>
  const callback: IntersectionObserverCallback = (entries, observer) => {
    entries.forEach((entry) => {
      if (!entry.isIntersecting) return;

      const embed = entry.target;

      const iframe = embed.children[0] as HTMLIFrameElement | null;
      const iframeOverlay = embed.children[1] as HTMLDivElement | null;
      if (!iframe || !iframeOverlay) return;

      const src = iframe.getAttribute("data-src");
      if (!src) return;

      iframe.setAttribute("src", src);
      iframe.onload = () => iframeOverlay.remove();

      observer.unobserve(embed);
    });
  };

  const observer = new IntersectionObserver(callback, {
    rootMargin: "1000px",
  });

  const targets = document.querySelectorAll(".youtube-embed");
  targets.forEach((target) => observer.observe(target));
</script>

実際に動作を確認してみると、Placeolder によって眩しさを防ぐことができています。

動画が目に見えて遅延して表示されることついては、読み込みのタイミングを調整したりダミーのサムネイル画像を表示しておくことで、より違和感ない改善を期待できそうです。

結果

無事遅延読み込みをすることができるようになったので、再度 PageSpeed Insights のスコアを測りました。 その結果がこちら!

React Router v6.4 で v5 から脱出したい

React Router v6 がリリースされてから凡そ半年が立ちました。

そして、v6.4 の足音が近づいてきています。 まだ v5 を利用している方は、これを機にバージョンを上げてみませんか?

上げたくても上げられない

もちろん訳あって上げられない場合もあるかと思います。

私が参加しているプロジェクトでも、流行に遅れてつい先日まで v5 でした。 なぜなら、v6 には preload API がなかったのです。*1

これは、Render-as-you-fetch パターンを導入しているアプリケーションにとって v6 バージョンアップへのハードルを上げていました。

v5 では、以下のように Route コンポーネントが持つ render prop*2 からデータ取得(Relay の loadQuery など)を挟んだ関数を渡すことができました。

<Route path="/home" render={() => {
  const queryRef = loadQuery(environment, homeQuery, ...)
  return <Home queryReference={queryRef} ... />
  }} />

そして v6.4 では、これを解決した loader prop がやってきます。 それを提供してくれるのが、DataBrowserRouter です。

現時点(2022/06/19)ではまだ beta ですが、公式ドキュメントが用意されているので見てみてください。 Data Quick Start | React Router

以下のように、loader prop に渡した関数が返すデータを useLoaderData フックを使って取得することができます。

// routing
  ...
  <Route element={App} loader={getAppData} />

// App.tsx
  const data = useLoaderData()
  return <div>{data.name}</div>

はい簡単ですね。ではサクッと v5 から脱出してしまいましょう。

React Router v6.4 と遷移アニメーション

しかし、然うは問屋が卸さないのがメジャーバージョンアップの作業です。

例として、v5 の Switch コンポーネントと framer-motion を組み合わせて、ルーティング遷移にアニメーションを実装している場合を考えてみます。 (もちろん render func でデータ取得も行っているとします)

<AnimatePresence>
  <Switch location={location} key={...} >
    <Route path="/" ... />
    <Route path="/:id" ... />
  </Switch>
</AnimatePresence>

さて、これを v6 用に直すと以下のように書けます。

<AnimatePresence>
  <Routes location={location} key={...} >
    <Route path="" ... />
    <Route path=":id" ... />
  </Routes>
</AnimatePresence>

Switch を Routes に置き換えるだけです。はい簡単ですね。

しかし、DataBrowserRouter は Route コンポーネントしか受け付けていません。そうです、Routesコンポーネントが使えないのです。 Switch を使ってアニメーションを行っていた場合は Route コンポーネントのみで実装する必要があるのです。

ここで、React Router v6 で追加された Outlet という機能が生きてきます。これを使うと、コンポーネントとルーティングの実装を分離させることができます。

先程 v6 用に書き直したものをさらに DataBrowserRouter とアニメーションを組み合わせた、v6.4 向けに書き直してみます。

// routing
<Route path="*" element={Wrapper} >
  <Route path="" element={...} />
  <Route path=":id" element={...} />
</Route>

// Wrapper.tsx
const Wrapper = () => {
const outlet = useOutlet()
return (
<AnimatePresence>
  <div key={location.pathname}>
    {outlet}
  </div>
</AnimatePresence>

上記のように、useOutlet が返すものを div や Suspense で囲って location.pathname を key に渡してあげると、 ルーティング遷移のタイミングでアニメーションを行うことができます。

注意

location が変化すると、ルーティング定義に応じて loader prop も変わるので useLoaderData が返す値も変化してしまいます。 そのため、遷移アニメーションでは旧ページが unmount されるまで useLoaderData が返す値をキャッシュしておく必要があります。

次のデモアプリでは useLoaderData を wrap した、カスタム hook を用意しました。*3

デモ

上記の実装を用いて簡単なアニメーション付きのアプリを作ってみたので参考にしてみてください。

実際のリポジトリこちら

さあ、あなたも React Router v5 から脱出しませんか?

参考資料

Remix & Cloudflare Pages で KV を触る

作業ログです。

Remix のアプリを Cloudflare Pages にデプロイして KV にアクセスするまでをやってみました。

github.com

結果としては、id:leader22 さんの記事*1と同じところにハマりました。 こちらの記事も参考になるかと思います。

違いはTypeScript用に型定義を用意したくらいでしょうか。

create-remix

とりあえず、脳死create-remixしていきます。

> npx create-remix@latest`
> Just the basics
> Cloudflare Pages
> TypeScript
> Run npm install ? > n

yarn を使いたいのでnpm installは断っておきます。

さて、テンプレートが展開されて完成しました。よかったですね。

とはいえ、このままyarn devyarn buildを行うとNo matches found: "dev:*"のように怒られてしまいますので修正しておきます。

- "build": "run-s build:*",
+ "build": "run-s 'build:*'",
- "dev": "remix build && run-p dev:*",
+ "dev": "remix build && run-p 'dev:*'",

azu on Twitter: "No matches found: "dev:*"ってなるのなんでだろな。 npm-run-all -p dev:api dev:worker みたいな個別指定ならいけるな。 * がどこかで展開されてるのかな?"

KV access in Pages Functions

間違ったやり方

私が躓いた、Cloudflare Pages にとって間違っていた方法です。 「remix cloudflare kv」などで調べると、その多くが Cloudflare Workers での実装なのでそのまま写すと動いてくれません。

Remix | Data Loading

Remix のドキュメントでは KV の名前をそのまま呼べると書かれており、加えてそれ用の型定義を宣言しておくことをオススメされます。

例えば KV の名前がGOOD_KVNAMEだとすると、実装イメージは以下のような感じです。

// global.d.ts
declare const GOOD_KVNAME: KVNamespace

// app/routes/index.tsx
export const loader: LoaderFunction = async () => {
  const cached = await GOOD_KVNAME.get<Cache>('cache-key', 'json')
  ...
}

RemixをCloudflare Workersで動かす & KVでデータをキャッシュする

しかし、これらは Cloudflare Pages の環境では動きません。ではどうしたら良いのでしょうか?

正しいやり方

まずはローカルで実行するために、package.jsonにあるdevスクリプトに手を加えます。

https://developers.cloudflare.com/pages/platform/functions/#develop-and-preview-locally

ドキュメントに従って、静的アセットのディレクトリを指定した後ろにKV_NAMESPACEを渡します。

package.json
- "dev:wrangler": "cross-env NODE_ENV=development wrangler pages dev ./public",
+ "dev:wrangler": "cross-env NODE_ENV=development wrangler pages dev ./public -k GOOD_KVNAME",

ローカルでの準備が終わったので、yarn devで開発環境を立ち上げてみます。すると、以下のようなエラーが返ってきます。

You must use the 2nd `env` parameter passed to exported handlers/Durable Object constructors, or `context.env` with Pages Functions.

どうやら、Pages Functions から KVNamespace を指定するには context.env から取得する必要があるようです。

プロジェクトルートにある、テンプレートから生成されたserver.jsを見てみると以下のような実装になっています。 この実装から、ルーティングファイルにあるloaderに渡ってくるリクエストのcontextcontext.envの値が入っているのが分かります。

// server.js
const handleRequest = createPagesFunctionHandler({
  build,
  mode: process.env.NODE_ENV,
  getLoadContext: context => context.env,
})

つまり、ルーティングファイルのloaderに渡ってくる context から、以下のように呼び出すことが出来ます。

const loader: LoaderFunction = async ({context}) => {
  const kv = context.GOOD_KVNAME as KVNamespace
  ...
}

とはいえ、context: anyを無理やりKVNamespaceへキャストするのもムズムズするので型定義を用意します。

// global.d.ts
import type {AppData, DataFunctionArgs} from '@remix-run/cloudflare'

interface LoaderFunctionArgs extends Omit<DataFunctionArgs, 'context'> {
  context: {
    GOOD_NAMESPACE: KVNamespace
  }
}

type LoaderFunctionResult = Promise<Response> | Response | Promise<AppData> | AppData

declare global {
  type LoaderFunction = (args: LoaderFunctionArgs) => LoaderFunctionResult
}

型定義を用意したことで、キャストの必要がなくなり以下のような実装になりました。

// app/routes/index.tsx
import {useLoaderData} from '@remix-run/react'

type Cache = {
  a: number
}

type LoaderData = {
  pageCache: Cache
}

export const loader: LoaderFunction = async ({context}): Promise<LoaderData> => {
  const kv = context.GOOD_KVNAME
  const cached = await kv.get<Cache>('cache-key', 'json')
  const updateCache = {...cached, a: (cached?.a ?? 0) + 1}
  await kv.set('cache-key', updateCache)
  return updateCache
}

以上でとりあえずは動くようになりました。

やっと開発が始められます。やったね。

cdkで定義したDynamoDBのテーブルをDynamoDBLocalに作る

この記事はPRを含みます

cdkを使っている場合のDynamoDBLocalには、どのようにテーブルを作ってますか?

皆さんは、アプリケーションの構成をcdkによって定義している際のローカル開発環境のDynamoDBのテーブル作成はどうしていますか。

LocalStack - A fully functional local cloud stack

localstackを用いた、cdk deployによってローカル開発環境のDynamoDBを用意している人も多いかと思います。

しかし、localstackはDynamoDBを使うだけなのに起動サービスにcloudformationを必要としていたりと、DynamoDBだけ欲しいんじゃーというかたには大げさではないでしょうか。

amazon/dynamodb-local

localstack以外でローカル環境にDynamoDBを用意する方法としては、Dockerイメージのamazon/dynamodb-localを使うのが一般的でしょうか。*1

dynamodb-localaws dynamodbコマンドで操作が出来ます。

例えばテーブルを作成するには、テーブル定義を書いたtable.jsonを用意してaws dynamodb create-table --cli-input-json file://table.jsonとコマンドを実行します。 以後紹介する方法はこれを利用してテーブルを作成することになります。

cdk synth(synthesize)

aws dynamodbでテーブルを作成するにはjsonファイルが必要になります。

そこで、cdk synthを実行してみましょう。 すると、cdk.outディレクトリへDynamoDBのテーブル定義が吐き出されます。

このファイル自体はCloudFormationテンプレートなので、ブログタイトルのcdkで定義したテーブルというよりはCloudFormationテンプレートのテーブル定義からDynamoDBLocalにテーブルを作成する方法と言った方が正しいかもしれません。

とはいえ、cdk synsthした結果をDynamoDBLocalのテーブルを作成するにはどんな手段があるのか見ていきましょう。

cloudformation-dynamodb-export · PyPI

これはPythonで書かれたスクリプトです。使用する際は、pipでインストールします。 最後に紹介する方法に変更する前はこちらを利用していました。

CloudFormationの定義からDynamoDB localのTableを作成する

こちらはRuby製のスクリプトです。MacならRubyが既に入っているので、そのまま使えそうですね。

npm scriptsで完結させる

いろいろ方法はありましたが、せっかくaws-cdkをnpmで管理しているので、npm完結したいですよね?

なにか良さげなパッケージはないのでしょうか?

ここに良さげなパッケージがあるので、こちらを使いましょう。

ErgoFriend/cf-to-dynamodb-schema

www.npmjs.com

実際にaws-cdkを使った/exampleフォルダがあるのでそこを見るとよくわかると思います。

使う際には、下記のようにcdk synthした結果のCloudFormationテンプレートを指定すると、ローカル環境のDynamoDBにテーブルが出来上がります。

cf-to-dynamodb-schema create-table ./cdk.out/SampleStack.template.json -e http://localhost:8000 -p c2dexample

*1:serverless-dynamodb-localもあると思いますが、今回はcdkをメインとしたいのでおいておきましょう。

TypeScript: 渡した値が返ることを保証したい

メモです

例えばstyle要素のように、渡せる値が限られているプロパティに動的な値を渡したいとします。

このとき、渡す型を明示しないで戻り値の型を判別可能な関数を用意します。 イメージとしては以下のような型を持つpositionに、indexに応じて動的に値を渡すことになります。

type Position = "absolute" | "static"
type Top = number
type Style = {position: Position; top: Top;}
const style: Style = {
  // error: Type 'number | "static"' is not assignable to type 'Position'.
  position: getStyle(index, {first: 100, second: "static"}),
  // ok
  top: getStyle(index, {first: 100, second: 50}),
}

上記の例では、getStyle()にはpositionとtopが取りうるPosition,Topどちらもを渡すことができますが、positionプロパティはPosition型のみを受け入れるのでgetStyleへ渡した数値100が返る可能性があるとエラーで教えてくれます。

1. Generics

TypeScript: Documentation - Generics

最初に思いついたGenericsを下記のように使ってみます。

const getStyle = <T extends StyleValue>(index, style: { first: T; second: T; }): T => { .. }
const style: Style = { position: getStyle(0, { first: "static", second: 0 })}
> Type 'number' is not assignable to type '"static"'.

もちろんgetStyle<Position | Top>(...)とすれば可能ですが、今回は渡した値によって返す型を決めたいのでこの方法は取りません。

2. プロパティごとにGenericsを用意するする

TypeScript: Documentation - Generics

Genericsは複数の型を渡せるので、それぞれのプロパティに用意してみます。

const getStyle = <A extends StyleValue, B extends StyleValue>(index,
  style: { first: A; second: B; }): A | B => { ... }

戻り値をUnion型で指定できるのでよさそうです。 しかしstyleへ渡すプロパティが増えていくと、A|B|C|D|...冗長化しそうです。

3. Indexed Access Types

TypeScript: Documentation - Indexed Access Types

冗長化を防ぐ方法として、Indexed Access Typesというものがあります。 これはその型のプロパティを呼ぶことで、その型を入手することが出来ます。 type User = { id: number; name: string; }という型を定義すると、User["id"]でidの型であるnumberを取得できます。 また、keyof UserでUser型が持つプロパティ名のUnion型を得ることが出来ます。それをUser型に渡すと以下ようになります。

User[keyof User] ≒ User["id"|"name"] ≒ User["id"] | User["name"] ≒ number | string
type StyleMap = {
  first: StyleValue;
  second: StyleValue;
};
const getStyle = <T extends StyleMap>(index, style: T): T[keyof StyleMap] => { ... };

プロパティの数だけ宣言するよりもスッキリました。 Indexed Access Typesでも、渡したstyleの各プロパティが持つ値のUnion型を返すことが出来ているのでよさそうです。

使っているnpmパッケージの名前が変わってた

依存パッケージのアップデートはしていますか?

フロントエンドの開発を行っていく中で依存パッケージのアップデートは欠かせないものだと思います。

例えば、以下のようなnpm updateyarn upgradenpm-check-updatesコマンドラインから実行したり、GitHub Appで自動化するならRenovate や Dependabot を使っているのではないでしょうか。

今回は、4ヶ月もアップデートから取り残されていたパッケージを見つけたお話です。

パッケージ名が変わっていた

今回は開発中にreact-use-gestureのドキュメントを読みに行く際に、パッケージ名前が@use-gesture/reactへと変更していたことに気が付きました。

名前が変更された後の最初の安定版10.0.0が4ヶ月前にリリースされていたようです。

パッケージ名の変更を知る

一般的にnpmではパッケージが非推奨になるとdeprecatedが付きます。

今回パッケージ名が変更されていた react-use-gesture では、以下のように表示されています。

This package has been deprecated
Author message:

This package is no longer maintained. Please use @use-gesture/react instead

最近更新されていないパッケージを察してnpmレジストリのページへ毎回見に行くわけにもいきません。 では、どのような方法で非推奨かを知ると良いのでしょうか?

パッケージの情報をみる

Changelogや噂を追ってない限りは、 npm レジストリに対してパッケージの情報を問い合わせて非推奨かどうかを確認するしかないようです。

❯ npm info react-use-gesture | grep -i deprecated
DEPRECATED ⚠️  - This package is no longer maintained. Please use @use-gesture/react instead

❯ yarn info react-use-gesture | grep deprecated
  deprecated: 'This package is no longer maintained. Please use @use-gesture/react instead'

jq

会社のSlackで聞いた際に、id:utgwkk が教えてくださったissueには jq を駆使して グラフィカルに表示してくれるスクリプトがありました。 npmコマンドを叩いて、使用しているパッケージが非推奨かをビジュアライズしてくれます。

github.com

check-is-deprecated

これも内部的にはnpmコマンドを叩いているようです。 package.jsonのファイル名指定すると、非推奨のパッケージを検出してくれます。

github.com

❯ check-is-deprecated -f package.json
react-use-gesture:
✖ npm
❯ check-is-deprecated -f package.json
🎉 All is OK!

自動化したい

cliを実行する

最初に思いつくのは、上記の手動でcliを叩くものをGitHub Actionsなどで実行する方法です。

tinovyatkin/action-check-deprecated-js-deps

似たようなことを GitHub Actions で公開されているものがありました。 npm レジストリに対してパッケージの情報を問い合わせて、非推奨かどうかを確認しているようです。

github.com

Renovate Replacement Presets

今回、調べていくなかで Renovate の issue に似たような話題をみつけました。

github.com

これは、なんらかのパッケージ名が変更された場合に変更後のパッケージ名に置き換える機能を用意しよう。というものでした。

issueが作られたのが2018年、PRの作成日が2020年2月でマージされたのが2021年11月だったので長らく議論していたようです。

紆余曲折あり最終的にリリースされたのは、変更前後のパッケージ名をマッピング出来るReplacement Presetsという機能のようです。

docs.renovatebot.com

この機能は、自動という訳ではなく変更後のパッケージ名が判明してから設定する必要があります。

今回のreact-use-gestureなら以下のように書くことが出来ます。

{
  "packageRules": [
    {
      "matchDatasources": [
        "npm"
      ],
      "matchPackageNames": [
        "react-use-gesture"
      ],
      "replacementName": "@use-gesture/react",
      "replacementVersion": "10.0.0"
    }
  ]
}

まとめ

長く開発・運用をしていると、いつのまにか非推奨になっているパッケージもあるかと思います。 普段の依存更新に加えて、パッケージ名の変更はどうのように追えばよいのか調べてみました。

現時点では、使用しているパッケージの名前が変更されているかはnpmレジストリを直接確認していくしかないようです。

かっこよく自動で変更後のパッケージ名をsuggestする機能を作りたいですね。