こんにちは。スタディサプリの小中新規開発チームで Web エンジニアをしている @YutaUra です。
昨今の JavaScript を利用する開発では source maps の存在が欠かせません。今回の記事では、私たちが直面した source maps が壊れてしまう場面と、 source maps を壊さないために取り組んでいることについて話したいと思います。
source maps とは
source maps に関しては web.dev の What are source maps? がとてもわかりやすいと思います。
簡単に説明をすると、 JavaScript(TypeScript, JSX 含む) を利用する開発ではトランスパイルやバンドルなどによって、元のファイルとは異なる JavaScript に変換して実行させることがしばしばあります。その際に、変換後のファイルと変換前のファイルを繋ぐ役目を持っているのが source maps となっています。
source maps を使うことで、変換後のプログラムで発生したエラーから、エラーの発生位置を変換前のコードに逆算することができたりします。
私たちのプロダクトでは Sentry を使ってエラーモニタリングを行っています。
Sentry を使うと次のような表示でエラーを確認することができます。
しかし、 source maps がないと次のような表示になってしまいます。
バンドルされた巨大な js ファイルからエラーの発生位置がわかったところで、元のコードを推測することは非常に困難です。 このように source maps がない状態ではエラーの発生原因を特定および解消が著しく難しい状況となることがわかると思います。
不意に壊れる source maps
そんな source maps ですが、私たちの意図しないところで壊れてしまうことが度々あります。
私たちが遭遇した source maps が壊れてしまった場面としては次の2つがありました。
- Next.js で swc を使ったビルドを行う際
- Sentry の新しい機能を利用しようとした際
実際、 source maps が壊れてしまう機会というのはそれほど多くはありません。しかし source maps が壊れたことに気づけるのは、エラーが発生して Sentry での表示がおかしくなったタイミングだったりするので、気づけるまでに時間がかかったり、壊れた原因が分かりにくくなってしまったり、気づいた時のエラーは調査困難な状態となってしまったり、不都合な側面が大きいです。
source maps を守るために取り組んでいること
source maps が壊れていることに気づくのが Sentry でエラーが発生したタイミングでは遅すぎます。そこで私たちは CI 上で source maps が壊れていないかを検知する仕組みを独自で作成しました。 その仕組みによって source maps が壊れそうな場面を何度か未然に防ぐことができたので、皆さんにも共有したいと思います。
現時点で検知しているポイントは2点あります。
- 変換後のファイルを source maps を通して逆変換した時に、変換前のファイルと一致すること
- 変換後のファイルに
//# sourceMappingURL=
といった source maps への参照が含まれていて、それが正しいこと
1点目の検証については直感的な内容になっているかと思います。2点目の検証ポイントは、ライブラリをアップデートのタイミングで source maps への参照が消えてしまう問題が発生するケースがあり、その場合に Sentry で正しく読み取ることができなくなる問題があったため追加しました。
ポイントは source-map
というライブラリを使うことです。source-map を使うと、 source maps から source maps が示す変換前のファイルと変換前のファイルパス相当の情報を得ることができるので、実際のファイルと比較することで source maps が壊れていないかを検証することができます。
具体的なコードは下部に添付するので、ぜひカスタマイズして使ってみてください。
私たちは添付した処理に加えて、変換元のファイルと変換後のファイルに差分がある場合に diff
を使って差分を視覚的に分かりやすく表示するなどの工夫を行ったりもしています。
おわりに
PR ごとに GitHub Actions などで Next.js のビルドの直後に実行することで source maps が壊れていないかを検証することができるようになりました。
この処理を作成したことで、 Next.js やその他ビルドに関係するライブラリの更新がしやすくなったので、とても助かっています!
付録
私たちのプロダクトでは Next.js, webpack を利用していますが、他の構成の場合でも大まかな方針は変わらないと思います。
import { dirname, join, relative } from 'node:path' import { readFile } from 'node:fs/promises' import { globSync } from 'glob' import { SourceMapConsumer } from 'source-map' const SOURCEMAP_COMMENT_PREFIX = `//# sourceMappingURL=` const getSourcemapReference = async (jsFilepath: string) => { // 実際は source maps の参照を得るのに十分なファイルの後ろ 1000 文字だけ読み込むような実装にしています const endOfFile = await readFile(jsFilepath, "utf-8") const sourcemap = endOfFile.split('\n').find((line) => line.startsWith(SOURCEMAP_COMMENT_PREFIX)) if (!sourcemap) return null const actual = sourcemap.replace(SOURCEMAP_COMMENT_PREFIX, '').trim() return actual } const main = async () => { const baseDirectory = process.cwd() const chunkDirectory = join(baseDirectory, 'build/_next/static/chunks') const sourcemapFiles = globSync(join(chunkDirectory, '**/*.map')) if (sourcemapFiles.length === 0) { // Next.js のバージョンアップとかでディレクトリ構成が変わったりした場合に検知できるようにする throw new Error('No sources found. Maybe the directory structure has changed.') } for (const sourcemapFile of sourcemapFiles) { const sourcemapPath = join(chunkDirectory, sourcemapFile) const jsPath = sourcemapPath.replace(/\.map$/, '') const sourcemapReference = await getSourcemapReference(jsPath) // //# sourceMappingURL= が存在しない if (!sourcemapReference) throw new Error("source maps の参照が存在しません") // //# sourceMappingURL= で示されたファイル名が "<js file>.map" の形式になっていない if (relative(dirname(jsPath), join(chunkDirectory, sourcemapFile)) !== sourcemapReference) throw new Error("source maps の形式が意図通りじゃないかも") const map = await readFile(sourcemapPath, 'utf-8') const sourceMap = await new SourceMapConsumer(JSON.parse(map)) const sources = sourceMap.sources .map((source) => ({ source, content: sourceMap.sourceContentFor(source), })) // Next.js の pages router を利用しているため、 pages, src, node_modules 以下のファイルだけ検証したい // webpack://_N_E/src で始まるか webpack://_N_E/pages で始まるか webpack://_N_E/node_modules で始まるコードのみを対象にする // それ以外には virtual file みたいなよくわからんファイルがあるが、ここでは無視する .filter( (v) => (v.source.startsWith('webpack://_N_E/src') || v.source.startsWith('webpack://_N_E/pages') || v.source.startsWith('webpack://_N_E/node_modules')) && (v.source.endsWith('.js') || v.source.endsWith('.jsx') || v.source.endsWith('.ts') || v.source.endsWith('.tsx')), ) for (const { source, content } of sources) { if (!content) continue // 変換元ファイルの path. プロジェクトやディレクトリ構成に応じて調整が必要 const originalPath = join(baseDirectory, source.replace('webpack://_N_E/', '')) const originalContent = await readFile(originalPath, 'utf-8') if (originalContent !== content) throw new Error("変換元と変換後でファイルの内容が異なります") } } } main()